diff --git a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/domain/Metadata.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/domain/Metadata.java index 57457f82d77..ca9e50ec3bf 100644 --- a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/domain/Metadata.java +++ b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/domain/Metadata.java @@ -70,6 +70,11 @@ public final class Metadata implements Status { @JsonInclude(JsonInclude.Include.NON_EMPTY) private String failureMessage; + @Schema(nullable = true, + description = "The map-service region resolved when location is auto-select.") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String resolvedMapLocation; + public Metadata() { this((String) null); } @@ -102,6 +107,7 @@ public Metadata(Metadata metadata) { this.parentId = metadata.parentId; this.originId = metadata.originId; this.failureMessage = metadata.failureMessage; + this.resolvedMapLocation = metadata.resolvedMapLocation; } public String getId() { @@ -260,6 +266,14 @@ public void setFailureMessage(String failureMessage) { this.failureMessage = failureMessage; } + public String getResolvedMapLocation() { + return resolvedMapLocation; + } + + public void setResolvedMapLocation(String resolvedMapLocation) { + this.resolvedMapLocation = resolvedMapLocation; + } + @Override public void solvingStarted() { if (solverStatus != SolvingStatus.DATASET_COMPUTED diff --git a/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/Headers.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/Headers.java index 7926f8ad96b..cc701bb7e06 100644 --- a/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/Headers.java +++ b/model/definition/src/main/java/ai/timefold/solver/model/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/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/MapEnrichmentContext.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/MapEnrichmentContext.java new file mode 100644 index 00000000000..e5c738c5cb4 --- /dev/null +++ b/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/MapEnrichmentContext.java @@ -0,0 +1,22 @@ +package ai.timefold.solver.model.definition.internal; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Side-channel used by the maps enricher to publish the resolved map-service location to the + * SolverWorker, which then writes it onto the dataset's {@code Metadata} so it propagates through + * insight events. + */ +@ApplicationScoped +public class MapEnrichmentContext { + + private String resolvedMapLocation; + + public void setResolvedMapLocation(String location) { + this.resolvedMapLocation = location; + } + + public String getResolvedMapLocation() { + return this.resolvedMapLocation; + } +} diff --git a/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/platform/EnvironmentVars.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/platform/EnvironmentVars.java index 096849e3357..b024bc2bc97 100644 --- a/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/platform/EnvironmentVars.java +++ b/model/definition/src/main/java/ai/timefold/solver/model/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/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java index 91eb4e5387e..53625edd67f 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -8,6 +8,7 @@ import jakarta.inject.Inject; import ai.timefold.solver.model.definition.api.enrichment.SolverModelEnricher; +import ai.timefold.solver.model.definition.internal.MapEnrichmentContext; import ai.timefold.solver.model.definition.internal.error.ErrorCodes; import ai.timefold.solver.model.definition.internal.error.TimefoldRuntimeException; import ai.timefold.solver.model.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/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/CacheItem.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/CacheItem.java index 73a40602864..1c6f222b3e4 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/CacheItem.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/CacheItem.java @@ -6,5 +6,6 @@ import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistance; public record CacheItem(TravelTimeAndDistance travelTimeAndDistance, List locations, String hash, - List locationsOutOfMap) { + List locationsOutOfMap, String resolvedMapLocation) { + } diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java index 1b430a15c32..d279121dd65 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java @@ -4,6 +4,7 @@ import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_INVALIDATE_MATRIX_HEADER; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_NOT_IN_MAP; +import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATION_HEADER; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; import static ai.timefold.solver.model.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/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTest.java b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTest.java index ef3940e4547..26cab66b5f3 100644 --- a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTest.java +++ b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTest.java @@ -1,10 +1,12 @@ package ai.timefold.solver.model.maps.service.client.api; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import jakarta.inject.Inject; +import ai.timefold.solver.model.definition.internal.MapEnrichmentContext; import ai.timefold.solver.model.maps.api.model.Location; import ai.timefold.solver.model.maps.api.model.travel.TravelDistance; import ai.timefold.solver.model.maps.api.model.travel.TravelTime; @@ -12,6 +14,8 @@ import ai.timefold.solver.model.maps.service.client.util.SampleModel; import ai.timefold.solver.model.maps.service.integration.api.LocationsAwareSolverModel; import ai.timefold.solver.model.maps.service.test.api.MapServiceApiWiremockExtensions; +import ai.timefold.solver.model.maps.service.test.impl.DistanceGetUpdateResponseTransformer; +import ai.timefold.solver.model.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/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java index 3b64af1ab75..4310ab4ba95 100644 --- a/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java +++ b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/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/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java index a6b947b458d..4b67d30c957 100644 --- a/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java +++ b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/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/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java index b1e05cbe1e9..7739749f206 100644 --- a/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java +++ b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/DistanceGetUpdateResponseTransformer.java @@ -1,5 +1,20 @@ package ai.timefold.solver.model.maps.service.test.impl; +import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; +import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; +import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; +import static ai.timefold.solver.model.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.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider; +import ai.timefold.solver.model.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/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/HaversineDistanceResponseTransformer.java b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/HaversineDistanceResponseTransformer.java index 6e2b59fe720..8db0231164f 100644 --- a/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/HaversineDistanceResponseTransformer.java +++ b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/impl/HaversineDistanceResponseTransformer.java @@ -2,6 +2,7 @@ import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_NOT_IN_MAP; +import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATION_HEADER; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER; import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_PROVIDER_HEADER; import static ai.timefold.solver.model.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/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/SolverWorker.java b/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/SolverWorker.java index 4852267cc71..91d62286b73 100644 --- a/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/SolverWorker.java +++ b/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/SolverWorker.java @@ -51,6 +51,7 @@ import ai.timefold.solver.model.definition.api.validation.ModelValidator; import ai.timefold.solver.model.definition.api.validation.ValidationBuilder; import ai.timefold.solver.model.definition.api.validation.dto.ValidationResult; +import ai.timefold.solver.model.definition.internal.MapEnrichmentContext; import ai.timefold.solver.model.definition.internal.error.ErrorCodes; import ai.timefold.solver.model.definition.internal.error.ItemNotFoundException; import ai.timefold.solver.model.definition.internal.error.TimefoldRuntimeException; @@ -119,6 +120,8 @@ public class SolverWorker { private final SolverModelEnrichmentDirectorService enrichmentDirectorService; + private final MapEnrichmentContext mapEnrichmentContext; + private final TerminationService terminationService; private final Emitter datasetValidatedEventEmitter; @@ -173,6 +176,7 @@ public SolverWorker(@ConfigProperty(name = "timefold.application.name") Optional ModelConvertorBase modelConvertor, SolverModelEnricherService enricherService, SolverModelEnrichmentDirectorService enrichmentDirectorService, + MapEnrichmentContext mapEnrichmentContext, TerminationService terminationService, ShutdownExecutor shutdownExecutor, ShutdownOnTerminate shutdownOnTerminate, @@ -199,6 +203,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; @@ -349,6 +354,7 @@ private void computeOutputs(String id) { ModelConfig modelConfig = Configuration.getSafeModelConfig(configuration); SolverModel solverModel = createSolverModel(modelInput, modelConfig); + applyResolvedMapLocation(metadata); solutionManager.update(solverModel); // Store the updated solution @@ -563,6 +569,7 @@ protected SolverModel notifyOnStart(String id, ModelInput modelInput, ModelOutpu Metadata metadata = storageService.getMetadata(id); SolverModel solverModel = createSolverModel(modelInput, modelConfig, modelOutput); + applyResolvedMapLocation(metadata); if (metadata.getSolverStatus() == SolvingStatus.DATASET_COMPUTED || metadata.getSolverStatus() == SolvingStatus.SOLVING_SCHEDULED) { metadata.solvingStarted(); @@ -612,6 +619,18 @@ private SolverModel enrichModel(SolverModel solverModel) { : enricherService.enrich(solverModel); } + private void applyResolvedMapLocation(Metadata metadata) { + String resolved = mapEnrichmentContext.getResolvedMapLocation(); + if (resolved == null || metadata == null) { + return; + } + metadata.setResolvedMapLocation(resolved); + String configuredLocation = System.getenv(EnvironmentVars.ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION); + if (EnvironmentVars.MAP_SERVICE_LOCATION_AUTO_SELECT.equalsIgnoreCase(configuredLocation)) { + LOGGER.info("Auto-select map resolved to '{}' for dataset {}.", resolved, metadata.getId()); + } + } + protected void notifyOnInit(String id, SolverModel solverModel, boolean isTerminatedEarly, EventProducerId eventProducerId) { LOGGER.debug("Notify run init for id {}", id);