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 57457f82d7..ca9e50ec3b 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 7926f8ad96..cc701bb7e0 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 0000000000..e2b8e54c67 --- /dev/null +++ b/model/definition/src/main/java/ai/timefold/solver/model/definition/internal/MapEnrichmentContext.java @@ -0,0 +1,24 @@ +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 consumeResolvedMapLocation() { + String value = this.resolvedMapLocation; + this.resolvedMapLocation = null; + return value; + } +} 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 096849e335..b024bc2bc9 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 91eb4e5387..53625edd67 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 73a4060286..1c6f222b3e 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 1b430a15c3..d279121dd6 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-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 3b64af1ab7..4310ab4ba9 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 a6b947b458..4b67d30c95 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/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 4852267cc7..87a8372d81 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.consumeResolvedMapLocation(); + 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);