diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/Headers.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/Headers.java index a945be40db..d83e133207 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/Headers.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/Headers.java @@ -6,6 +6,8 @@ public class Headers { public static final String X_MAPS_PROVIDER_HEADER = "X-TF-MAPS-PROVIDER"; + public static final String X_MAPS_LOCATION_HEADER = "X-TF-MAPS-LOCATION"; + public static final String X_MAPS_CACHE_ID = "X-TF-MAPS-CACHE-ID"; public static final String X_MAPS_RESPONSE_CHUNK_BYTES = "X-TF-MAPS-CHUNK-BYTES"; diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/MapEnrichmentContext.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/MapEnrichmentContext.java index e69de29bb2..a757d49432 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/MapEnrichmentContext.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/MapEnrichmentContext.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.service.definition.internal; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class MapEnrichmentContext { + + private String resolvedMapLocation; + + public void setResolvedMapLocation(String location) { + this.resolvedMapLocation = location; + } + + public String getResolvedMapLocation() { + return this.resolvedMapLocation; + } +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractDatasetEvent.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractDatasetEvent.java index 0eb0546c21..f2fda55901 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractDatasetEvent.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractDatasetEvent.java @@ -10,6 +10,8 @@ public abstract sealed class AbstractDatasetEvent extends AbstractEvent private final Metadata metadata; + private String resolvedMapLocation; + protected AbstractDatasetEvent(Metadata metadata) { super(metadata.getId()); // Safe copy of the run to avoid external modifications. @@ -20,6 +22,14 @@ public Metadata getMetadata() { return metadata; } + public String getResolvedMapLocation() { + return resolvedMapLocation; + } + + public void setResolvedMapLocation(String resolvedMapLocation) { + this.resolvedMapLocation = resolvedMapLocation; + } + @Override public String toString() { return getClass().getSimpleName() + "{" + diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java index 39395c5351..28f07a91a3 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java @@ -32,6 +32,18 @@ public class EnvironmentVars { */ public static final String ENV_TIMEFOLD_TENANT_NAME = "AI_TIMEFOLD_TENANT_NAME"; + /** + * Configured map-service location: either a concrete region (e.g. {@code us-georgia}) or the + * sentinel {@link #MAP_SERVICE_LOCATION_AUTO_SELECT} for runtime region resolution. + */ + public static final String ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION = "AI_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION"; + + /** + * Sentinel value for {@link #ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION}: tells the maps-service + * to auto-select the region at request time based on the locations being routed. + */ + public static final String MAP_SERVICE_LOCATION_AUTO_SELECT = "auto-select"; + /** * Kubernetes API specific environment variables that are set based on execution information like pod and node */ diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index b29b68b80b..158a4e8e80 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -8,6 +8,7 @@ import jakarta.inject.Inject; import ai.timefold.solver.service.definition.api.enrichment.SolverModelEnricher; +import ai.timefold.solver.service.definition.internal.MapEnrichmentContext; import ai.timefold.solver.service.definition.internal.error.ErrorCodes; import ai.timefold.solver.service.definition.internal.error.TimefoldRuntimeException; import ai.timefold.solver.service.maps.api.model.Location; @@ -30,10 +31,14 @@ public class TravelTimeMatrixEnricher implements SolverModelEnricher enrich(LocationsAwareSolverModel solverMo location.setDistanceMatrix(travelTimeAndDistance.travelTimeAndDistance().distance()); }); solverModel.setLocationsNotInMap(convertIdxToLocations(travelTimeAndDistance.locationsNotInMapIdx(), locations)); + mapEnrichmentContext.setResolvedMapLocation(travelTimeAndDistance.resolvedMapLocation()); return solverModel; } diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/CacheItem.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/CacheItem.java index e441a74e48..aa1ecdf71d 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/CacheItem.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/CacheItem.java @@ -6,6 +6,6 @@ import ai.timefold.solver.service.maps.service.integration.internal.model.TravelTimeAndDistance; public record CacheItem(TravelTimeAndDistance travelTimeAndDistance, List locations, String hash, - List locationsOutOfMap) { + List locationsOutOfMap, String resolvedMapLocation) { } diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceClientImpl.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceClientImpl.java index d974d9eac1..2e460aa524 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceClientImpl.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceClientImpl.java @@ -4,6 +4,7 @@ import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_INVALIDATE_MATRIX_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATIONS_NOT_IN_MAP; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATION_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_RESPONSE_CHUNK_BYTES; @@ -135,8 +136,9 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List // If there are no updates, return from cache LOGGER.info("Distance matrix in cache is up-to-date, returning from cache"); assertLocationsAreInCache(locations); - return new TravelTimeAndDistanceWithMetadata(travelTimeAndDistanceSingleItemCache.get().travelTimeAndDistance(), - travelTimeAndDistanceSingleItemCache.get().locationsOutOfMap()); + CacheItem cached = travelTimeAndDistanceSingleItemCache.get(); + return new TravelTimeAndDistanceWithMetadata(cached.travelTimeAndDistance(), + cached.locationsOutOfMap(), cached.resolvedMapLocation()); } else { // If there are updates, process them and update cache LOGGER.info("Distance matrix in cache is not up-to-date, processing updates"); @@ -205,7 +207,8 @@ private TravelTimeAndDistanceWithMetadata getFromCacheOrRequest(List l if (travelTimeAndDistanceSingleItemCache.isInCache(id)) { LOGGER.info("Distance matrix without location set name in cache, returning from cache"); CacheItem cacheItem = travelTimeAndDistanceSingleItemCache.get(); - return new TravelTimeAndDistanceWithMetadata(cacheItem.travelTimeAndDistance(), cacheItem.locationsOutOfMap()); + return new TravelTimeAndDistanceWithMetadata(cacheItem.travelTimeAndDistance(), cacheItem.locationsOutOfMap(), + cacheItem.resolvedMapLocation()); } // If it does not exist, request from maps-service and store by hash of locations @@ -237,6 +240,7 @@ private TravelTimeAndDistanceWithMetadata getAndStoreInCache(List loca private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Response response, String localCacheId) { String matrixHash = response.getHeaderString(X_MAPS_MATRIX_HASH_HEADER); String provider = response.getHeaderString(X_MAPS_PROVIDER_HEADER); + String resolvedMapLocation = response.getHeaderString(X_MAPS_LOCATION_HEADER); String tenant = response.getHeaderString(X_TENANT_ID_HEADER); String cacheId = response.getHeaderString(X_MAPS_CACHE_ID); String locationsNotInMapString = response.getHeaderString(X_MAPS_LOCATIONS_NOT_IN_MAP); @@ -256,11 +260,13 @@ private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Respons throw new IllegalArgumentException("No provider found to convert travel time and distance response."); } - TravelTimeAndDistanceWithMetadata travelTimeAndDistance = + TravelTimeAndDistanceWithMetadata raw = convertResponse(provider, chunkBytes, responseLocations, data, locationsNotInMap); + TravelTimeAndDistanceWithMetadata travelTimeAndDistance = new TravelTimeAndDistanceWithMetadata( + raw.travelTimeAndDistance(), raw.locationsNotInMapIdx(), resolvedMapLocation); travelTimeAndDistanceSingleItemCache.put(localCacheId, new CacheItem(travelTimeAndDistance.travelTimeAndDistance(), responseLocations, matrixHash, - locationsNotInMap)); + locationsNotInMap, resolvedMapLocation)); return travelTimeAndDistance; } catch (IllegalDistanceResponseException e) { @@ -275,6 +281,7 @@ private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Respons private TravelTimeAndDistanceWithMetadata processUpdateAndStoreInCache(Response response, String locationSetName) { String matrixHash = response.getHeaderString(X_MAPS_MATRIX_HASH_HEADER); String provider = response.getHeaderString(X_MAPS_PROVIDER_HEADER); + String resolvedMapLocation = response.getHeaderString(X_MAPS_LOCATION_HEADER); String tenant = response.getHeaderString(X_TENANT_ID_HEADER); String cacheId = response.getHeaderString(X_MAPS_CACHE_ID); String locationsNotInMapString = response.getHeaderString(X_MAPS_LOCATIONS_NOT_IN_MAP); @@ -294,15 +301,18 @@ private TravelTimeAndDistanceWithMetadata processUpdateAndStoreInCache(Response throw new IllegalArgumentException("No provider found to convert travel time and distance update."); } - TravelTimeAndDistanceWithMetadata travelTimeAndDistance = + TravelTimeAndDistanceWithMetadata raw = convertUpdate(provider, chunkBytes, responseLocations, data, cacheItem.locationsOutOfMap(), locationsNotInMap); + String effectiveMapLocation = resolvedMapLocation != null ? resolvedMapLocation : cacheItem.resolvedMapLocation(); + TravelTimeAndDistanceWithMetadata travelTimeAndDistance = new TravelTimeAndDistanceWithMetadata( + raw.travelTimeAndDistance(), raw.locationsNotInMapIdx(), effectiveMapLocation); List newLocations = Stream.concat(cacheItem.locations().stream(), responseLocations.stream()).toList(); if (locationSetName != null && matrixHash != null) { travelTimeAndDistanceSingleItemCache.put(locationSetName, new CacheItem(travelTimeAndDistance.travelTimeAndDistance(), newLocations, matrixHash, - locationsNotInMap)); + locationsNotInMap, effectiveMapLocation)); } return travelTimeAndDistance; diff --git a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTest.java b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTest.java index ec07b480e0..70c5d8ffa2 100644 --- a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTest.java +++ b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTest.java @@ -1,10 +1,12 @@ package ai.timefold.solver.service.maps.service.client.api; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import jakarta.inject.Inject; +import ai.timefold.solver.service.definition.internal.MapEnrichmentContext; import ai.timefold.solver.service.maps.api.model.Location; import ai.timefold.solver.service.maps.api.model.travel.TravelDistance; import ai.timefold.solver.service.maps.api.model.travel.TravelTime; @@ -12,6 +14,8 @@ import ai.timefold.solver.service.maps.service.client.util.SampleModel; import ai.timefold.solver.service.maps.service.integration.api.LocationsAwareSolverModel; import ai.timefold.solver.service.maps.service.test.api.MapServiceApiWiremockExtensions; +import ai.timefold.solver.service.maps.service.test.impl.DistanceGetUpdateResponseTransformer; +import ai.timefold.solver.service.maps.service.test.impl.HaversineDistanceResponseTransformer; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; @@ -28,6 +32,9 @@ public class TravelTimeMatrixEnricherTest { @Inject TravelTimeMatrixEnricher enricher; + @Inject + MapEnrichmentContext mapEnrichmentContext; + @Test void testRemoteConnectionWithMapServer() { Location l1 = new Location(0, 0); @@ -41,6 +48,8 @@ void testRemoteConnectionWithMapServer() { Assertions.assertThat(enrich.getLocations().getFirst().getTravelTimeTo(l2)).isEqualTo(TravelTime.of(11322L)); Assertions.assertThat(enrich.getLocations().getFirst().getDistanceTo(l2)).isEqualTo(TravelDistance.of(157249L)); Assertions.assertThat(enrich.getLocationsNotInMap()).isEmpty(); + Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation()) + .isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION); } @Test @@ -97,6 +106,8 @@ void testDistanceMatrixWithUpdates() { Assertions.assertThat(enrich.getLocations().get(1).getDistanceTo(l1)).isEqualTo(TravelDistance.of(157249L)); Assertions.assertThat(enrich.getLocations().get(2).getDistanceTo(l4)).isEqualTo(TravelDistance.of(157178L)); Assertions.assertThat(enrich.getLocationsNotInMap()).isEmpty(); + Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation()) + .isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION); } @Test @@ -153,4 +164,27 @@ void testDistanceMatrixWithLocationsOutOfMap() { Assertions.assertThat(enrich.getLocations().get(2).getDistanceTo(l4)).isEqualTo(TravelDistance.ZERO); } + @Test + void updatePathFallsBackToCachedMapLocationWhenHeaderIsMissing() { + // First enrich populates the cache for "with-updates" via the POST full-matrix path, + // which emits X_MAPS_LOCATION_HEADER and cache stores resolvedMapLocation="us-georgia". + SampleModel seed = new SampleModel(DistanceGetUpdateResponseTransformer.UPDATE_AWARE_LOCATION_SET_NAME, + DistanceGetUpdateResponseTransformer.UPDATE_OLD_LOCATIONS); + enricher.enrich(seed); + Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation()) + .isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION); + + // Second enrich adds a new location and cache hit triggers the GET-update path. + // The transformer returns a 200 update WITHOUT X_MAPS_LOCATION_HEADER, so + // processUpdateAndStoreInCache must fall back to the value stored in the CacheItem. + List withNewLocation = new ArrayList<>(DistanceGetUpdateResponseTransformer.UPDATE_OLD_LOCATIONS); + withNewLocation.addAll(DistanceGetUpdateResponseTransformer.UPDATE_NEW_LOCATIONS); + SampleModel updated = new SampleModel(DistanceGetUpdateResponseTransformer.UPDATE_AWARE_LOCATION_SET_NAME, + withNewLocation); + enricher.enrich(updated); + + Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation()) + .isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION); + } + } diff --git a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java index 8d346d7eb3..7df4476e36 100644 --- a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java +++ b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java @@ -3,5 +3,10 @@ import java.util.List; public record TravelTimeAndDistanceWithMetadata(TravelTimeAndDistance travelTimeAndDistance, - List locationsNotInMapIdx) { + List locationsNotInMapIdx, String resolvedMapLocation) { + + public TravelTimeAndDistanceWithMetadata(TravelTimeAndDistance travelTimeAndDistance, + List locationsNotInMapIdx) { + this(travelTimeAndDistance, locationsNotInMapIdx, null); + } } diff --git a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java index 78f63ef19f..6d98be827d 100644 --- a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java +++ b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java @@ -3,5 +3,10 @@ import java.io.InputStream; import java.util.List; -public record TravelTimeAndDistanceMatrixResponse(InputStream response, List locationsOutOfMapIndexes) { +public record TravelTimeAndDistanceMatrixResponse(InputStream response, List locationsOutOfMapIndexes, + String resolvedMapLocation) { + + public TravelTimeAndDistanceMatrixResponse(InputStream response, List locationsOutOfMapIndexes) { + this(response, locationsOutOfMapIndexes, null); + } } diff --git a/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java b/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java index 5693f95327..9b71cdc4e1 100644 --- a/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java +++ b/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java @@ -1,5 +1,20 @@ package ai.timefold.solver.service.maps.service.test.impl; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_RESPONSE_CHUNK_BYTES; + +import java.io.ByteArrayInputStream; +import java.io.SequenceInputStream; +import java.util.Collections; +import java.util.List; + +import ai.timefold.solver.service.maps.api.model.Location; +import ai.timefold.solver.service.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider; +import ai.timefold.solver.service.maps.service.integration.internal.model.TravelTimeAndDistance; + +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; import com.github.tomakehurst.wiremock.extension.ResponseDefinitionTransformerV2; import com.github.tomakehurst.wiremock.http.ResponseDefinition; @@ -9,10 +24,70 @@ public class DistanceGetUpdateResponseTransformer implements ResponseDefinitionT public static String TRANSFORMER_NAME = "distance-get-update-response-transformer"; + /** + * Location-set name that opts a test into a real (non-410) update response. The test must seed the cache + * with {@link #UPDATE_OLD_LOCATIONS} on a first enrich, then add {@link #UPDATE_NEW_LOCATIONS} on a second. + * Used to drive the {@code processUpdateAndStoreInCache} path, which is otherwise unreachable via wiremock. + */ + public static final String UPDATE_AWARE_LOCATION_SET_NAME = "with-updates"; + public static final List UPDATE_OLD_LOCATIONS = List.of(new Location(0, 0), new Location(1, 1)); + public static final List UPDATE_NEW_LOCATIONS = List.of(new Location(2, 2)); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HaversineTravelTimeAndDistanceMatrixProvider provider = + new HaversineTravelTimeAndDistanceMatrixProvider(objectMapper); + @Override public ResponseDefinition transform(ServeEvent serveEvent) { + String options = serveEvent.getRequest().queryParameter("options").firstValue(); + String matrixHash = serveEvent.getRequest().queryParameter("matrix-hash").firstValue(); + // "00" is the client's sentinel for "no cached hash yet"; it returns 410 so it falls back to POST. + // For any other hash on the opt-in location set, deliver a real chunked update. + if (!isUpdateAware(options) || "00".equals(matrixHash)) { + return new ResponseDefinitionBuilder().withStatus(410).build(); + } + try { + return buildUpdateResponse(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isUpdateAware(String options) { + if (options == null) { + return false; + } + for (String entry : options.split(",")) { + String[] kv = entry.split(":"); + if (kv.length == 2 && "locationSetName".equals(kv[0].trim()) + && UPDATE_AWARE_LOCATION_SET_NAME.equals(kv[1].trim())) { + return true; + } + } + return false; + } + + private ResponseDefinition buildUpdateResponse() throws Exception { + // One matrix chunk (old × new) is enough to drive processUpdateAndStoreInCache. The test only + // checks the resolvedMapLocation header fallback, not the joined matrix contents. + TravelTimeAndDistance oldXNew = objectMapper.readValue( + provider.calculateTravelTimeAndDistance(UPDATE_OLD_LOCATIONS, UPDATE_NEW_LOCATIONS, + Collections.emptyMap()).response(), + TravelTimeAndDistance.class); + + byte[] newLocationsBytes = objectMapper.writeValueAsBytes(UPDATE_NEW_LOCATIONS); + byte[] matrixBytes = objectMapper.writeValueAsBytes(oldXNew); + return new ResponseDefinitionBuilder() - .withStatus(410) + .withHeader("Content-Type", "application/json") + .withHeader(X_MAPS_PROVIDER_HEADER, provider.getProvider()) + // Intentionally NO X_MAPS_LOCATION_HEADER: drives the cache-fallback branch. + .withHeader(X_MAPS_MATRIX_HASH_HEADER, "hash2") + .withHeader(X_MAPS_RESPONSE_CHUNK_BYTES, String.valueOf(matrixBytes.length)) + .withHeader(X_MAPS_LOCATIONS_CHUNK_BYTES, newLocationsBytes.length + ",0") + .withStatus(200) + .withBody(new SequenceInputStream(new ByteArrayInputStream(newLocationsBytes), + new ByteArrayInputStream(matrixBytes)).readAllBytes()) .build(); } diff --git a/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/HaversineDistanceResponseTransformer.java b/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/HaversineDistanceResponseTransformer.java index de05e0408a..4b8d8f2ee3 100644 --- a/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/HaversineDistanceResponseTransformer.java +++ b/service/maps/service-test/src/main/java/ai/timefold/solver/service/maps/service/test/impl/HaversineDistanceResponseTransformer.java @@ -2,6 +2,7 @@ import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATIONS_NOT_IN_MAP; +import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_LOCATION_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; import static ai.timefold.solver.service.definition.internal.Headers.X_MAPS_RESPONSE_CHUNK_BYTES; @@ -28,6 +29,7 @@ public class HaversineDistanceResponseTransformer implements ResponseDefinitionTransformerV2 { public static String TRANSFORMER_NAME = "haversine-distance-response-transformer"; + public static final String RESOLVED_MAP_LOCATION = "us-georgia"; private static final List locationsOutOfMap = List.of(new Location(-90, -90)); private final ObjectMapper objectMapper = new ObjectMapper(); private final HaversineTravelTimeAndDistanceMatrixProvider provider = @@ -63,6 +65,7 @@ public ResponseDefinition transform(ServeEvent serveEvent) { return new ResponseDefinitionBuilder() .withHeader("Content-Type", "application/json") .withHeader(X_MAPS_PROVIDER_HEADER, provider.getProvider()) + .withHeader(X_MAPS_LOCATION_HEADER, RESOLVED_MAP_LOCATION) .withHeader(X_MAPS_MATRIX_HASH_HEADER, "hash") .withHeader(X_MAPS_LOCATIONS_CHUNK_BYTES, metadataChunkBytes.stream().map(Object::toString).collect(Collectors.joining(","))) @@ -104,6 +107,7 @@ public ResponseDefinition transform(ServeEvent serveEvent) { return new ResponseDefinitionBuilder() .withHeader("Content-Type", "application/json") .withHeader(X_MAPS_PROVIDER_HEADER, provider.getProvider()) + .withHeader(X_MAPS_LOCATION_HEADER, RESOLVED_MAP_LOCATION) .withHeader(X_MAPS_RESPONSE_CHUNK_BYTES, response.chunkBytes().stream().map(Object::toString).collect(Collectors.joining(","))) .withHeader(X_MAPS_MATRIX_HASH_HEADER, "hash") diff --git a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java index 883870a66d..9f1b72735b 100644 --- a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java +++ b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java @@ -49,9 +49,11 @@ import ai.timefold.solver.service.definition.api.validation.ModelValidator; import ai.timefold.solver.service.definition.api.validation.ValidationBuilder; import ai.timefold.solver.service.definition.api.validation.dto.ValidationResult; +import ai.timefold.solver.service.definition.internal.MapEnrichmentContext; import ai.timefold.solver.service.definition.internal.error.ErrorCodes; import ai.timefold.solver.service.definition.internal.error.ItemNotFoundException; import ai.timefold.solver.service.definition.internal.error.TimefoldRuntimeException; +import ai.timefold.solver.service.definition.internal.events.AbstractDatasetEvent; import ai.timefold.solver.service.definition.internal.events.AbstractEvent; import ai.timefold.solver.service.definition.internal.events.BestSolutionEvent; import ai.timefold.solver.service.definition.internal.events.DatasetComputedEvent; @@ -117,6 +119,8 @@ public class SolverWorker { private final SolverModelEnrichmentDirectorService enrichmentDirectorService; + private final MapEnrichmentContext mapEnrichmentContext; + private final TerminationService terminationService; private final Emitter datasetValidatedEventEmitter; @@ -157,6 +161,8 @@ public class SolverWorker { private AtomicBoolean shuttingDown = new AtomicBoolean(false); + private final AtomicBoolean resolvedMapLocationLogged = new AtomicBoolean(false); + private BroadcastProcessor> processor = BroadcastProcessor.create(); @Inject @@ -171,6 +177,7 @@ public SolverWorker(@ConfigProperty(name = "timefold.application.name") Optional ModelConvertorBase modelConvertor, SolverModelEnricherService enricherService, SolverModelEnrichmentDirectorService enrichmentDirectorService, + MapEnrichmentContext mapEnrichmentContext, TerminationService terminationService, ShutdownExecutor shutdownExecutor, ShutdownOnTerminate shutdownOnTerminate, @@ -197,6 +204,7 @@ public SolverWorker(@ConfigProperty(name = "timefold.application.name") Optional this.modelConvertor = (ModelConvertor) modelConvertor; this.enricherService = enricherService; this.enrichmentDirectorService = enrichmentDirectorService; + this.mapEnrichmentContext = mapEnrichmentContext; this.terminationService = terminationService; this.shutdownExecutor = shutdownExecutor; this.shutdownOnTerminate = shutdownOnTerminate; @@ -241,6 +249,7 @@ private void reportExecutionEnvironmentInfo() { private void sendEvent(Emitter emitter, AbstractEvent event) { try { + enrichResolvedMapLocation(event); emitter.send(event).toCompletableFuture().get(EMITTER_TIMEOUT, TimeUnit.SECONDS); } catch (ExecutionException | InterruptedException | TimeoutException e) { if (e instanceof InterruptedException) { @@ -776,6 +785,19 @@ public void notifyOnFailure(Object id, Throwable throwable) { } } + private void enrichResolvedMapLocation(AbstractEvent event) { + String resolved = mapEnrichmentContext.getResolvedMapLocation(); + if (resolved == null || !(event instanceof AbstractDatasetEvent datasetEvent)) { + return; + } + datasetEvent.setResolvedMapLocation(resolved); + String configuredLocation = System.getenv(EnvironmentVars.ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION); + if (EnvironmentVars.MAP_SERVICE_LOCATION_AUTO_SELECT.equalsIgnoreCase(configuredLocation) + && resolvedMapLocationLogged.compareAndSet(false, true)) { + LOGGER.info("Auto-select map resolved to '{}' for dataset {}.", resolved, datasetEvent.getId()); + } + } + private ModelOutput convertToModelOutput(String id, SolverModel solverModel) { try { return modelConvertor.toModelOutput(solverModel);