Skip to content

Commit 71f89e1

Browse files
feat: Add effective map location to metadata events (#2321)
Fetch resolved map location from enricher and set it in metadata, so that events have access to the resolved map location.
1 parent 42bce4e commit 71f89e1

13 files changed

Lines changed: 221 additions & 12 deletions

File tree

model/definition/src/main/java/ai/timefold/solver/model/definition/api/domain/Metadata.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ public final class Metadata<Score_> implements Status<Score_> {
7070
@JsonInclude(JsonInclude.Include.NON_EMPTY)
7171
private String failureMessage;
7272

73+
@Schema(nullable = true,
74+
description = "The map-service region resolved when location is auto-select.")
75+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
76+
private String resolvedMapLocation;
77+
7378
public Metadata() {
7479
this((String) null);
7580
}
@@ -102,6 +107,7 @@ public Metadata(Metadata<Score_> metadata) {
102107
this.parentId = metadata.parentId;
103108
this.originId = metadata.originId;
104109
this.failureMessage = metadata.failureMessage;
110+
this.resolvedMapLocation = metadata.resolvedMapLocation;
105111
}
106112

107113
public String getId() {
@@ -260,6 +266,14 @@ public void setFailureMessage(String failureMessage) {
260266
this.failureMessage = failureMessage;
261267
}
262268

269+
public String getResolvedMapLocation() {
270+
return resolvedMapLocation;
271+
}
272+
273+
public void setResolvedMapLocation(String resolvedMapLocation) {
274+
this.resolvedMapLocation = resolvedMapLocation;
275+
}
276+
263277
@Override
264278
public void solvingStarted() {
265279
if (solverStatus != SolvingStatus.DATASET_COMPUTED

model/definition/src/main/java/ai/timefold/solver/model/definition/internal/Headers.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ public class Headers {
66

77
public static final String X_MAPS_PROVIDER_HEADER = "X-TF-MAPS-PROVIDER";
88

9+
public static final String X_MAPS_LOCATION_HEADER = "X-TF-MAPS-LOCATION";
10+
911
public static final String X_MAPS_CACHE_ID = "X-TF-MAPS-CACHE-ID";
1012

1113
public static final String X_MAPS_RESPONSE_CHUNK_BYTES = "X-TF-MAPS-CHUNK-BYTES";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ai.timefold.solver.model.definition.internal;
2+
3+
import jakarta.enterprise.context.ApplicationScoped;
4+
5+
/**
6+
* Side-channel used by the maps enricher to publish the resolved map-service location to the
7+
* SolverWorker, which then writes it onto the dataset's {@code Metadata} so it propagates through
8+
* insight events.
9+
*/
10+
@ApplicationScoped
11+
public class MapEnrichmentContext {
12+
13+
private String resolvedMapLocation;
14+
15+
public void setResolvedMapLocation(String location) {
16+
this.resolvedMapLocation = location;
17+
}
18+
19+
public String getResolvedMapLocation() {
20+
return this.resolvedMapLocation;
21+
}
22+
}

model/definition/src/main/java/ai/timefold/solver/model/definition/internal/platform/EnvironmentVars.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public class EnvironmentVars {
3232
*/
3333
public static final String ENV_TIMEFOLD_TENANT_NAME = "AI_TIMEFOLD_TENANT_NAME";
3434

35+
/**
36+
* Configured map-service location: either a concrete region (e.g. {@code us-georgia}) or the
37+
* sentinel {@link #MAP_SERVICE_LOCATION_AUTO_SELECT} for runtime region resolution.
38+
*/
39+
public static final String ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION = "AI_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION";
40+
41+
/**
42+
* Sentinel value for {@link #ENV_TIMEFOLD_PLATFORM_MAP_SERVICE_LOCATION}: tells the maps-service
43+
* to auto-select the region at request time based on the locations being routed.
44+
*/
45+
public static final String MAP_SERVICE_LOCATION_AUTO_SELECT = "auto-select";
46+
3547
/**
3648
* Kubernetes API specific environment variables that are set based on execution information like pod and node
3749
*/

model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import jakarta.inject.Inject;
99

1010
import ai.timefold.solver.model.definition.api.enrichment.SolverModelEnricher;
11+
import ai.timefold.solver.model.definition.internal.MapEnrichmentContext;
1112
import ai.timefold.solver.model.definition.internal.error.ErrorCodes;
1213
import ai.timefold.solver.model.definition.internal.error.TimefoldRuntimeException;
1314
import ai.timefold.solver.model.maps.api.model.Location;
@@ -30,10 +31,14 @@ public class TravelTimeMatrixEnricher implements SolverModelEnricher<LocationsAw
3031

3132
private final MapServiceOptionsSupplier optionsSupplier;
3233

34+
private final MapEnrichmentContext mapEnrichmentContext;
35+
3336
@Inject
34-
public TravelTimeMatrixEnricher(MapService mapService, MapServiceOptionsSupplier optionsSupplier) {
37+
public TravelTimeMatrixEnricher(MapService mapService, MapServiceOptionsSupplier optionsSupplier,
38+
MapEnrichmentContext mapEnrichmentContext) {
3539
this.mapService = mapService;
3640
this.optionsSupplier = optionsSupplier;
41+
this.mapEnrichmentContext = mapEnrichmentContext;
3742
}
3843

3944
@Retry(maxRetries = 5, delay = 1, delayUnit = ChronoUnit.SECONDS, abortOn = {
@@ -61,6 +66,7 @@ public LocationsAwareSolverModel<?> enrich(LocationsAwareSolverModel<?> solverMo
6166
location.setDistanceMatrix(travelTimeAndDistance.travelTimeAndDistance().distance());
6267
});
6368
solverModel.setLocationsNotInMap(convertIdxToLocations(travelTimeAndDistance.locationsNotInMapIdx(), locations));
69+
mapEnrichmentContext.setResolvedMapLocation(travelTimeAndDistance.resolvedMapLocation());
6470
return solverModel;
6571
}
6672

model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/CacheItem.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistance;
77

88
public record CacheItem(TravelTimeAndDistance travelTimeAndDistance, List<Location> locations, String hash,
9-
List<Integer> locationsOutOfMap) {
9+
List<Integer> locationsOutOfMap, String resolvedMapLocation) {
10+
1011
}

model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_INVALIDATE_MATRIX_HEADER;
55
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_CHUNK_BYTES;
66
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATIONS_NOT_IN_MAP;
7+
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_LOCATION_HEADER;
78
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_MATRIX_HASH_HEADER;
89
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_PROVIDER_HEADER;
910
import static ai.timefold.solver.model.definition.internal.Headers.X_MAPS_RESPONSE_CHUNK_BYTES;
@@ -135,8 +136,9 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List<Location>
135136
// If there are no updates, return from cache
136137
LOGGER.info("Distance matrix in cache is up-to-date, returning from cache");
137138
assertLocationsAreInCache(locations);
138-
return new TravelTimeAndDistanceWithMetadata(travelTimeAndDistanceSingleItemCache.get().travelTimeAndDistance(),
139-
travelTimeAndDistanceSingleItemCache.get().locationsOutOfMap());
139+
CacheItem cached = travelTimeAndDistanceSingleItemCache.get();
140+
return new TravelTimeAndDistanceWithMetadata(cached.travelTimeAndDistance(),
141+
cached.locationsOutOfMap(), cached.resolvedMapLocation());
140142
} else {
141143
// If there are updates, process them and update cache
142144
LOGGER.info("Distance matrix in cache is not up-to-date, processing updates");
@@ -205,7 +207,8 @@ private TravelTimeAndDistanceWithMetadata getFromCacheOrRequest(List<Location> l
205207
if (travelTimeAndDistanceSingleItemCache.isInCache(id)) {
206208
LOGGER.info("Distance matrix without location set name in cache, returning from cache");
207209
CacheItem cacheItem = travelTimeAndDistanceSingleItemCache.get();
208-
return new TravelTimeAndDistanceWithMetadata(cacheItem.travelTimeAndDistance(), cacheItem.locationsOutOfMap());
210+
return new TravelTimeAndDistanceWithMetadata(cacheItem.travelTimeAndDistance(), cacheItem.locationsOutOfMap(),
211+
cacheItem.resolvedMapLocation());
209212
}
210213

211214
// If it does not exist, request from maps-service and store by hash of locations
@@ -237,6 +240,7 @@ private TravelTimeAndDistanceWithMetadata getAndStoreInCache(List<Location> loca
237240
private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Response response, String localCacheId) {
238241
String matrixHash = response.getHeaderString(X_MAPS_MATRIX_HASH_HEADER);
239242
String provider = response.getHeaderString(X_MAPS_PROVIDER_HEADER);
243+
String resolvedMapLocation = response.getHeaderString(X_MAPS_LOCATION_HEADER);
240244
String tenant = response.getHeaderString(X_TENANT_ID_HEADER);
241245
String cacheId = response.getHeaderString(X_MAPS_CACHE_ID);
242246
String locationsNotInMapString = response.getHeaderString(X_MAPS_LOCATIONS_NOT_IN_MAP);
@@ -256,11 +260,13 @@ private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Respons
256260
throw new IllegalArgumentException("No provider found to convert travel time and distance response.");
257261
}
258262

259-
TravelTimeAndDistanceWithMetadata travelTimeAndDistance =
263+
TravelTimeAndDistanceWithMetadata raw =
260264
convertResponse(provider, chunkBytes, responseLocations, data, locationsNotInMap);
265+
TravelTimeAndDistanceWithMetadata travelTimeAndDistance = new TravelTimeAndDistanceWithMetadata(
266+
raw.travelTimeAndDistance(), raw.locationsNotInMapIdx(), resolvedMapLocation);
261267
travelTimeAndDistanceSingleItemCache.put(localCacheId,
262268
new CacheItem(travelTimeAndDistance.travelTimeAndDistance(), responseLocations, matrixHash,
263-
locationsNotInMap));
269+
locationsNotInMap, resolvedMapLocation));
264270
return travelTimeAndDistance;
265271

266272
} catch (IllegalDistanceResponseException e) {
@@ -275,6 +281,7 @@ private TravelTimeAndDistanceWithMetadata processResponseAndStoreInCache(Respons
275281
private TravelTimeAndDistanceWithMetadata processUpdateAndStoreInCache(Response response, String locationSetName) {
276282
String matrixHash = response.getHeaderString(X_MAPS_MATRIX_HASH_HEADER);
277283
String provider = response.getHeaderString(X_MAPS_PROVIDER_HEADER);
284+
String resolvedMapLocation = response.getHeaderString(X_MAPS_LOCATION_HEADER);
278285
String tenant = response.getHeaderString(X_TENANT_ID_HEADER);
279286
String cacheId = response.getHeaderString(X_MAPS_CACHE_ID);
280287
String locationsNotInMapString = response.getHeaderString(X_MAPS_LOCATIONS_NOT_IN_MAP);
@@ -294,15 +301,18 @@ private TravelTimeAndDistanceWithMetadata processUpdateAndStoreInCache(Response
294301
throw new IllegalArgumentException("No provider found to convert travel time and distance update.");
295302
}
296303

297-
TravelTimeAndDistanceWithMetadata travelTimeAndDistance =
304+
TravelTimeAndDistanceWithMetadata raw =
298305
convertUpdate(provider, chunkBytes, responseLocations, data, cacheItem.locationsOutOfMap(),
299306
locationsNotInMap);
307+
String effectiveMapLocation = resolvedMapLocation != null ? resolvedMapLocation : cacheItem.resolvedMapLocation();
308+
TravelTimeAndDistanceWithMetadata travelTimeAndDistance = new TravelTimeAndDistanceWithMetadata(
309+
raw.travelTimeAndDistance(), raw.locationsNotInMapIdx(), effectiveMapLocation);
300310

301311
List<Location> newLocations = Stream.concat(cacheItem.locations().stream(), responseLocations.stream()).toList();
302312
if (locationSetName != null && matrixHash != null) {
303313
travelTimeAndDistanceSingleItemCache.put(locationSetName,
304314
new CacheItem(travelTimeAndDistance.travelTimeAndDistance(), newLocations, matrixHash,
305-
locationsNotInMap));
315+
locationsNotInMap, effectiveMapLocation));
306316
}
307317
return travelTimeAndDistance;
308318

model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
package ai.timefold.solver.model.maps.service.client.api;
22

3+
import java.util.ArrayList;
34
import java.util.List;
45
import java.util.Optional;
56

67
import jakarta.inject.Inject;
78

9+
import ai.timefold.solver.model.definition.internal.MapEnrichmentContext;
810
import ai.timefold.solver.model.maps.api.model.Location;
911
import ai.timefold.solver.model.maps.api.model.travel.TravelDistance;
1012
import ai.timefold.solver.model.maps.api.model.travel.TravelTime;
1113
import ai.timefold.solver.model.maps.service.client.util.RemoteMapServiceConfigurationProfile;
1214
import ai.timefold.solver.model.maps.service.client.util.SampleModel;
1315
import ai.timefold.solver.model.maps.service.integration.api.LocationsAwareSolverModel;
1416
import ai.timefold.solver.model.maps.service.test.api.MapServiceApiWiremockExtensions;
17+
import ai.timefold.solver.model.maps.service.test.impl.DistanceGetUpdateResponseTransformer;
18+
import ai.timefold.solver.model.maps.service.test.impl.HaversineDistanceResponseTransformer;
1519

1620
import org.assertj.core.api.Assertions;
1721
import org.junit.jupiter.api.Test;
@@ -28,6 +32,9 @@ public class TravelTimeMatrixEnricherTest {
2832
@Inject
2933
TravelTimeMatrixEnricher enricher;
3034

35+
@Inject
36+
MapEnrichmentContext mapEnrichmentContext;
37+
3138
@Test
3239
void testRemoteConnectionWithMapServer() {
3340
Location l1 = new Location(0, 0);
@@ -41,6 +48,8 @@ void testRemoteConnectionWithMapServer() {
4148
Assertions.assertThat(enrich.getLocations().getFirst().getTravelTimeTo(l2)).isEqualTo(TravelTime.of(11322L));
4249
Assertions.assertThat(enrich.getLocations().getFirst().getDistanceTo(l2)).isEqualTo(TravelDistance.of(157249L));
4350
Assertions.assertThat(enrich.getLocationsNotInMap()).isEmpty();
51+
Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation())
52+
.isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION);
4453
}
4554

4655
@Test
@@ -97,6 +106,8 @@ void testDistanceMatrixWithUpdates() {
97106
Assertions.assertThat(enrich.getLocations().get(1).getDistanceTo(l1)).isEqualTo(TravelDistance.of(157249L));
98107
Assertions.assertThat(enrich.getLocations().get(2).getDistanceTo(l4)).isEqualTo(TravelDistance.of(157178L));
99108
Assertions.assertThat(enrich.getLocationsNotInMap()).isEmpty();
109+
Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation())
110+
.isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION);
100111
}
101112

102113
@Test
@@ -153,4 +164,27 @@ void testDistanceMatrixWithLocationsOutOfMap() {
153164
Assertions.assertThat(enrich.getLocations().get(2).getDistanceTo(l4)).isEqualTo(TravelDistance.ZERO);
154165
}
155166

167+
@Test
168+
void updatePathFallsBackToCachedMapLocationWhenHeaderIsMissing() {
169+
// First enrich populates the cache for "with-updates" via the POST full-matrix path,
170+
// which emits X_MAPS_LOCATION_HEADER and cache stores resolvedMapLocation="us-georgia".
171+
SampleModel seed = new SampleModel(DistanceGetUpdateResponseTransformer.UPDATE_AWARE_LOCATION_SET_NAME,
172+
DistanceGetUpdateResponseTransformer.UPDATE_OLD_LOCATIONS);
173+
enricher.enrich(seed);
174+
Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation())
175+
.isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION);
176+
177+
// Second enrich adds a new location and cache hit triggers the GET-update path.
178+
// The transformer returns a 200 update WITHOUT X_MAPS_LOCATION_HEADER, so
179+
// processUpdateAndStoreInCache must fall back to the value stored in the CacheItem.
180+
List<Location> withNewLocation = new ArrayList<>(DistanceGetUpdateResponseTransformer.UPDATE_OLD_LOCATIONS);
181+
withNewLocation.addAll(DistanceGetUpdateResponseTransformer.UPDATE_NEW_LOCATIONS);
182+
SampleModel updated = new SampleModel(DistanceGetUpdateResponseTransformer.UPDATE_AWARE_LOCATION_SET_NAME,
183+
withNewLocation);
184+
enricher.enrich(updated);
185+
186+
Assertions.assertThat(mapEnrichmentContext.getResolvedMapLocation())
187+
.isEqualTo(HaversineDistanceResponseTransformer.RESOLVED_MAP_LOCATION);
188+
}
189+
156190
}

model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/model/TravelTimeAndDistanceWithMetadata.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@
33
import java.util.List;
44

55
public record TravelTimeAndDistanceWithMetadata(TravelTimeAndDistance travelTimeAndDistance,
6-
List<Integer> locationsNotInMapIdx) {
6+
List<Integer> locationsNotInMapIdx, String resolvedMapLocation) {
7+
8+
public TravelTimeAndDistanceWithMetadata(TravelTimeAndDistance travelTimeAndDistance,
9+
List<Integer> locationsNotInMapIdx) {
10+
this(travelTimeAndDistance, locationsNotInMapIdx, null);
11+
}
712
}

model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/provider/TravelTimeAndDistanceMatrixResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@
33
import java.io.InputStream;
44
import java.util.List;
55

6-
public record TravelTimeAndDistanceMatrixResponse(InputStream response, List<Integer> locationsOutOfMapIndexes) {
6+
public record TravelTimeAndDistanceMatrixResponse(InputStream response, List<Integer> locationsOutOfMapIndexes,
7+
String resolvedMapLocation) {
8+
9+
public TravelTimeAndDistanceMatrixResponse(InputStream response, List<Integer> locationsOutOfMapIndexes) {
10+
this(response, locationsOutOfMapIndexes, null);
11+
}
712
}

0 commit comments

Comments
 (0)