Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ That is most commonly the same model already being registered under a _different
Registering a model does not automatically subscribe your tenant to it; with `handleSubscription` set to `true`, the plugin also subscribes your tenant when it registers the model.
Without it, your tenant won't see the model on the platform after you deploy it.

The platform supports up to 3 versions of the same model and each version has assigned offset. The offset defines the lag from latest version.

- 0 is the default offset, it points to the latest version of the model.
- 1 points to the previous version of the model.
- 2 points to the version before the previous version of the model.

You can use the offset to update or undeploy a specific version of the model. The offset can be set via the `<offset>` in the Maven plugin configuration or via the command line with `-Dtimefold.model.offset=<offset>`. The offset is only used for update (PATCH) and undeploy (DELETE) operations. For example, if you want to update the previous version of the model, you can set `<offset>1</offset>` in the Maven plugin configuration or use `-Dtimefold.model.offset=1` in the command line when running the `timefold:deploy` goal. Similarly, if you want to undeploy the version before the previous version of the model, you can set `<offset>2</offset>` in the Maven plugin configuration or use `-Dtimefold.model.offset=2` in the command line when running the `timefold:undeploy` goal.

In most of the situations, there is no need in setting the offset, as the default value of 0 will always point to the latest version of the model. However, if you want to patch already deployed version e.g. bug fix or security release of the model, then use offset to target which model version to update.

[#_verify_on_the_platform]
== Verify on the platform

Expand Down
2 changes: 2 additions & 0 deletions service/tools/maven-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ These are the most important configuration properties for the plugin. They are s
- `timefold.model.type` (String) — registration type ("Private" or "Shared"). If not set the plugin infers it from `timefold.model.tenants` (multiple tenants -> `Shared`, otherwise `Private`). Set via `<type>` or `-Dtimefold.model.type=Shared`.
- `timefold.model.overwrite` (boolean, default=false) — when true and registration conflicts (HTTP 409), update existing model using PATCH. Use `-Dtimefold.model.overwrite=true`.
- `timefold.model.deploy.skip` (boolean, default=false) — skip deploy goal.
- `timefold.model.offset` (number, default=0) — used only for update (PATCH) to target one of existing versions within the same registration key. Allowed values: 0, 1, 2

=== Undeploy goal specific

- `tfp.model.undeploy.skip` (boolean, default=false) — skip undeploy goal. Note: the property used by the plugin for undeploy skip is `tfp.model.undeploy.skip` (not `timefold.model.undeploy.skip`).
- `timefold.model.offset` (number, default=0) — used to target one of existing versions within the same registration key. Allowed values: 0, 1, 2

== Authentication

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo {

protected static final String PROP_MODEL_KEY = "timefold.model.key";

protected static final String PROP_MODEL_OFFSET = "timefold.model.offset";

protected static final String PROP_MODEL_TENANTS = "timefold.model.tenants";

protected static final String PROP_MODEL_SUBS = "timefold.model.handleSubscription";
Expand Down Expand Up @@ -81,6 +83,12 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo {
@Parameter(property = PROP_MODEL_KEY, required = true)
protected String key;

/**
* Offset to be used when patching or unregistering
*/
@Parameter(property = PROP_MODEL_OFFSET, required = false, defaultValue = "0")
protected String offset;
Comment thread
rsynek marked this conversation as resolved.
Resolved

/**
* List of tenants this model should be registered for - used when type set to shared or private (in that case single tenant
* should be set)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public class DeployModelMojo extends AbstractPlatformModelMojo {
private static final String ERROR_CODE_MODEL_VERSION_ALREADY_EXISTS = "TFP-14005";
private static final String ERROR_CODE_UNKNOWN = "TFP-99999";

private static final String HEADER_WARN_MSG = "X-TF-WARN-MSG";

private static final List<String> OVERWRITABLE_CONFLICT_ERROR_CODES =
List.of(ERROR_CODE_REGISTRATION_KEY_ALREADY_EXISTS, ERROR_CODE_MODEL_VERSION_ALREADY_EXISTS);

Expand Down Expand Up @@ -142,6 +144,9 @@ public void execute() throws MojoExecutionException {
"Model %s (%s) has been successfully deployed into platform %s with registration key %s",
modelDescriptor.get("name").asText(), modelDescriptor.get("id").asText(), platformUrl,
key));

response.headers().firstValue(HEADER_WARN_MSG).ifPresent(warnMsg -> getLog().warn(warnMsg));
Comment thread
rsynek marked this conversation as resolved.
Resolved

} else if (response.statusCode() == 409) {
String conflictBody = response.body();
String errorCode = readErrorCode(conflictBody);
Expand All @@ -154,7 +159,8 @@ public void execute() throws MojoExecutionException {
modelDescriptor.get("id").asText(), errorCode, key, readErrorMessage(conflictBody)));
}
if (getPropertyOrParameter(PROP_MODEL_OVERWRITE, overwrite)) {

// configure which release offset to be targeted when patching the model registration, default is 0 (latest)
queryString.append("&releaseOffset=").append(getPropertyOrParameter(PROP_MODEL_OFFSET, offset));
requestURI =
URI.create(platformUrl + "/api/platform/v1/models/" + key + "?" + queryString.toString());
builder = HttpRequest.newBuilder().uri(requestURI).method("PATCH",
Expand All @@ -166,6 +172,8 @@ public void execute() throws MojoExecutionException {
"Model %s (%s) has been successfully updated on platform %s with registration key %s",
modelDescriptor.get("name").asText(), modelDescriptor.get("id").asText(), platformUrl,
key));

response.headers().firstValue(HEADER_WARN_MSG).ifPresent(warnMsg -> getLog().warn(warnMsg));
} else {
printErrorInfo(response.body());
throw new MojoExecutionException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ public void execute() throws MojoExecutionException {

URI requestURI =
URI.create(platformUrl + "/api/platform/v1/models/" + key + "?handleSubscription="
+ getPropertyOrParameter(PROP_MODEL_SUBS, handleSubscription));
+ getPropertyOrParameter(PROP_MODEL_SUBS, handleSubscription) + "&releaseOffset="
+ getPropertyOrParameter(PROP_MODEL_OFFSET, offset));

if (dryRun) {
getLog().info("DRY_RUN: Would perform DELETE on " + requestURI);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@
.withHeader("Content-Type", "application/json")
.withBody("{}")));

wm1.stubFor(post(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("close-to-limit"))
.willReturn(aResponse()
.withStatus(409)
.withHeader("Content-Type", "application/json")
.withBody(
"{\"id\":\"1\",\"code\":\"TFP-14001\",\"message\":\"Registered model conflicts with existing model (model_v1), unique model registration key is required\"}")));

wm1.stubFor(patch(urlPathEqualTo("/api/platform/v1/models/close-to-limit"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withHeader("X-TF-WARN-MSG",
"Limit of parallel model registration approaching (current value 8, limit 10)")
.withBody("{}")));

// represents a conflict the platform did not identify
wm1.stubFor(post(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("unknown-conflict"))
Expand Down Expand Up @@ -211,7 +227,29 @@

// verify plugin performed expected HTTP call
wm1.verify(1, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("model-key")));
.withQueryParam("registrationKey", equalTo("model-key"))
.withoutQueryParam("releaseOffset"));

// assert that plugin executed and produced expected logs
log.assertContains("Type is not explicitly specified so it was computed based on tenants and is set to Private",
Level.DEBUG);
log.assertContains("Model .* has been successfully deployed into platform.*", Level.INFO);
}

@Test
@MojoParameter(name = "descriptorOnly", value = "true")
@MojoParameter(name = "offset", value = "1")
@InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml")
public void testRegisterModelCustomOffset(DeployModelMojo mojo) throws Exception {

Check warning on line 243 in service/tools/maven-plugin/src/test/java/ai/timefold/solver/tools/maven/DeployModelMojoTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Update this method so that its implementation is not identical to "testRegisterModel" on line 222.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaGgapHzgkYWgQtU9r-K&open=AaGgapHzgkYWgQtU9r-K&pullRequest=2796
mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx"));
mojo.setLog(log);
mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl();
mojo.execute();

// verify plugin performed expected HTTP call
wm1.verify(1, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("model-key"))
.withoutQueryParam("releaseOffset"));

// assert that plugin executed and produced expected logs
log.assertContains("Type is not explicitly specified so it was computed based on tenants and is set to Private",
Expand All @@ -234,7 +272,54 @@
wm1.verify(1, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("existing")));

wm1.verify(1, patchRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing")));
wm1.verify(1, patchRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing"))
.withQueryParam("releaseOffset", equalTo("0")));

// assert that plugin executed and produced expected logs
log.assertContains("Model .* has been successfully updated on platform.*", Level.INFO);
}

@Test
@MojoParameter(name = "key", value = "close-to-limit")
@MojoParameter(name = "overwrite", value = "true")
@MojoParameter(name = "descriptorOnly", value = "true")
@InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml")
public void testRegisterModelWithPatchWarnOnLimit(DeployModelMojo mojo) throws Exception {
mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx"));
mojo.setLog(log);
mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl();
mojo.execute();

// verify plugin performed expected HTTP call
wm1.verify(1, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("close-to-limit")));

wm1.verify(1, patchRequestedFor(urlPathEqualTo("/api/platform/v1/models/close-to-limit"))
.withQueryParam("releaseOffset", equalTo("0")));

// assert that plugin executed and produced expected logs
log.assertContains("Model .* has been successfully updated on platform.*", Level.INFO);
log.assertContains("Limit of parallel model registration approaching \\(current value 8, limit 10\\)", Level.WARN);
}

@Test
@MojoParameter(name = "key", value = "existing")
@MojoParameter(name = "overwrite", value = "true")
@MojoParameter(name = "descriptorOnly", value = "true")
@MojoParameter(name = "offset", value = "1")
@InjectMojo(goal = "deploy", pom = "src/test/resources/project-to-test/pom.xml")
public void testRegisterModelWithPatchCustomOffset(DeployModelMojo mojo) throws Exception {
mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx"));
mojo.setLog(log);
mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl();
mojo.execute();

// verify plugin performed expected HTTP call
wm1.verify(1, postRequestedFor(urlPathEqualTo("/api/platform/v1/models"))
.withQueryParam("registrationKey", equalTo("existing")));

wm1.verify(1, patchRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing")).withQueryParam("releaseOffset",
equalTo("1")));

// assert that plugin executed and produced expected logs
log.assertContains("Model .* has been successfully updated on platform.*", Level.INFO);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.delete;
import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -112,7 +113,27 @@ public void testUndeploy(UndeployModelMojo mojo) throws Exception {
mojo.execute();

// verify plugin performed expected HTTP call
wm1.verify(1, deleteRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing")));
wm1.verify(1, deleteRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing")).withQueryParam("releaseOffset",
equalTo("0")));

// assert that plugin executed and produced expected logs
log.assertContains("Model .* has been successfully undeployed from platform.*", Level.INFO);
}

@Test
@MojoParameter(name = "key", value = "existing")
@MojoParameter(name = "offset", value = "1")
@InjectMojo(goal = "undeploy", pom = "src/test/resources/project-to-test/pom.xml")
public void testUndeployWithCustomOffset(UndeployModelMojo mojo) throws Exception {

mojo.setAccessTokenProvider(new TestAccessTokenProvider("xxxx"));
mojo.setLog(log);
mojo.platformUrl = wm1.getRuntimeInfo().getHttpBaseUrl();
mojo.execute();

// verify plugin performed expected HTTP call
wm1.verify(1, deleteRequestedFor(urlPathEqualTo("/api/platform/v1/models/existing")).withQueryParam("releaseOffset",
equalTo("1")));

// assert that plugin executed and produced expected logs
log.assertContains("Model .* has been successfully undeployed from platform.*", Level.INFO);
Expand Down
Loading