Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ 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 archive is normally only a few tens of kilobytes, so the upload itself is quick.
If the archive is large or the connection is slow, the plugin logs a progress line every few seconds while the upload is in flight, and once the upload finishes it reports that it is waiting for the platform to process the model rather than going silent.

[#_verify_on_the_platform]
== Verify on the platform

Expand Down
9 changes: 8 additions & 1 deletion service/tools/maven-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the
- If the platform responds with 409 (conflict) and `timefold.model.overwrite=true`, the plugin will PATCH `/api/platform/v1/models/{registrationKey}` to update the existing model
- The plugin reads the model descriptor to obtain name/id by extracting `timefold-model-descriptor.json` from the archive (or reading `target/timefold/timefold-model-descriptor.json` when present)
- The platform validates the model version (`timefold.application.version`) on registration. It must be a release version `vN` (e.g. `v1`), a git commit hash (1–40 lowercase hex characters), or the literal `SNAPSHOT`; any other value is rejected with HTTP 400.
- While the request is in flight the plugin reports progress. Archives of at least 1 MiB are announced up front, then at most one throttled line every 5 seconds reports the percentage uploaded, then a heartbeat reports that the upload is done and the platform is still processing, and finally a single line reports the total transferred and elapsed time. A normal deploy of an archive of a few tens of kilobytes completes before the first heartbeat and produces none of these lines. Note that `mvn -q` suppresses `[INFO]`, so it hides all progress output.

=== Undeploy goal (timefold:undeploy)

Expand Down Expand Up @@ -148,6 +149,12 @@ Goals are implemented as Mojos in `src/main/java/ai/timefold/solver/tools/maven`
- `ConfigureMojo` — writes build properties
- `DeployModelMojo` — handles upload/register/patch flow
- `UndeployModelMojo` — handles deletion
- `AbstractPlatformModelMojo` — common behavior, HTTP client, descriptor reading
- `AbstractPlatformModelMojo` — common behavior, HTTP client, descriptor reading, and `sendWithProgress`

Upload progress reporting lives in `src/main/java/ai/timefold/solver/tools/maven/http`:
Comment thread
winklerm marked this conversation as resolved.

- `CountingBodyPublisher` — a `BodyPublisher` wrapper that counts bytes handed to the client. Its `contentLength()` must stay delegated: returning `-1` switches the request to chunked transfer encoding and drops the `Content-Length` header.
- `UploadProgressReporter` — turns those byte counts into throttled log lines. Deliberately not thread safe, because it is only ever called from the mojo thread; see `Progress feedback and timeouts` above.
- `UploadProgressSettings` — the throttling parameters, with tighter values injected by tests via `DeployModelMojo.setProgressSettings`.

The plugin relies on the environment variable `TIMEFOLD_PAT` for authentication; tests provide a test helper to mock token retrieval.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
Expand All @@ -14,9 +17,15 @@
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import ai.timefold.solver.tools.maven.http.UploadProgressReporter;

import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugins.annotations.Parameter;
Expand Down Expand Up @@ -92,6 +101,49 @@
}
}

/**
* Sends the request and, while waiting for the response, lets the reporter log progress from this thread.
* <p>
* {@link HttpClient#sendAsync(HttpRequest, BodyHandler)} is used rather than
* {@link HttpClient#send(HttpRequest, BodyHandler)} so that this thread stays free to log while the request body is
* written by the client's own threads. The exception unwrapping below restores the exception types that the
* blocking send would have thrown, so callers and their error messages do not have to change.
*/
protected <T> HttpResponse<T> sendWithProgress(HttpRequest request, BodyHandler<T> responseBodyHandler,
UploadProgressReporter reporter) throws IOException, InterruptedException {
CompletableFuture<HttpResponse<T>> future = httpClient.sendAsync(request, responseBodyHandler);
long heartbeatMillis = Math.max(1L, reporter.getHeartbeatInterval().toMillis());
try {
while (true) {
try {

Check warning on line 118 in service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested try block into a separate method.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ_u77I-WmL3NC2ymHvN&open=AZ_u77I-WmL3NC2ymHvN&pullRequest=2580
HttpResponse<T> response = future.get(heartbeatMillis, TimeUnit.MILLISECONDS);
reporter.finished();
return response;
} catch (TimeoutException timeoutException) {
// not an error, the request is simply still in flight
reporter.heartbeat();
}
}
} catch (ExecutionException executionException) {
reporter.finished();
Throwable cause = executionException.getCause();
// HttpTimeoutException, ConnectException, SSLException, ... are all IOException subtypes and are rethrown
// as is, exactly as httpClient.send() would have thrown them
if (cause instanceof IOException ioException) {
throw ioException;
} else if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
} else if (cause instanceof Error error) {
throw error;
}
throw new IOException(cause == null ? executionException.getMessage() : cause.getMessage(),
cause == null ? executionException : cause);
} catch (InterruptedException interruptedException) {
future.cancel(true);
throw interruptedException;
}
}

protected void validate() {
Objects.requireNonNull(platformUrl, "Platform Url is mandatory");
Objects.requireNonNull(key, "Registration key is mandatory");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ private PlatformIdentityInfo fetchPlatformConfiguration() {
requestBuilder.uri(URI.create(platformUrl + "/api/platform/v1/aboutme?includeConfig=true"));

HttpRequest httpRequest = requestBuilder.build();
getLog().info("Fetching configuration from the Timefold Platform at " + platformUrl);
try {
HttpResponse<String> authResponse = httpClient.send(httpRequest, BodyHandlers.ofString());
if (authResponse.statusCode() == 200) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.timefold.solver.tools.maven;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
Expand All @@ -12,6 +13,10 @@
import java.util.List;
import java.util.Objects;

import ai.timefold.solver.tools.maven.http.CountingBodyPublisher;
import ai.timefold.solver.tools.maven.http.UploadProgressReporter;
import ai.timefold.solver.tools.maven.http.UploadProgressSettings;

import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
Expand Down Expand Up @@ -78,6 +83,15 @@ public class DeployModelMojo extends AbstractPlatformModelMojo {
@Parameter(property = PROP_DRY_RUN, required = false, defaultValue = "false")
private boolean dryRun;

private UploadProgressSettings progressSettings = UploadProgressSettings.defaults();

/**
* Visible for testing, so that progress reporting can be exercised without a slow upload.
*/
protected void setProgressSettings(UploadProgressSettings progressSettings) {
this.progressSettings = Objects.requireNonNull(progressSettings, "progressSettings");
}

public void execute() throws MojoExecutionException {
if (getPropertyOrParameter(PROP_MODEL_SKIP_DEPLOY, skip)) {
getLog().info("Model deployment skipped by configuration");
Expand Down Expand Up @@ -130,12 +144,8 @@ public void execute() throws MojoExecutionException {
getLog().info("DRY_RUN: Would perform POST on " + requestURI);
} else {

Builder builder =
HttpRequest.newBuilder().uri(requestURI).POST(BodyPublishers.ofFile(modelDescriptorArchivePath));

configureHttpRequest(builder);

HttpResponse<String> response = httpClient.send(builder.build(), BodyHandlers.ofString());
HttpResponse<String> response =
uploadModelDescriptor("POST", requestURI, modelDescriptorArchivePath);
if (response.statusCode() >= 200 && response.statusCode() < 400) {
getLog().info(
String.format(
Expand All @@ -157,10 +167,7 @@ public void execute() throws MojoExecutionException {

requestURI =
URI.create(platformUrl + "/api/platform/v1/models/" + key + "?" + queryString.toString());
builder = HttpRequest.newBuilder().uri(requestURI).method("PATCH",
BodyPublishers.ofFile(modelDescriptorArchivePath));
configureHttpRequest(builder);
response = httpClient.send(builder.build(), BodyHandlers.ofString());
response = uploadModelDescriptor("PATCH", requestURI, modelDescriptorArchivePath);
if (response.statusCode() >= 200 && response.statusCode() < 400) {
getLog().info(String.format(
"Model %s (%s) has been successfully updated on platform %s with registration key %s",
Expand Down Expand Up @@ -202,6 +209,29 @@ private String readErrorCode(String responseBody) {
return code == null ? ERROR_CODE_UNKNOWN : code;
}

/**
* Uploads the model descriptor archive, reporting progress while the request is in flight. A fresh
* {@link CountingBodyPublisher} per call is required: the overwrite PATCH is a genuinely new request, and sharing
* the counter across both verbs would report more than 100%.
*/
private HttpResponse<String> uploadModelDescriptor(String method, URI requestURI, Path archivePath)
throws IOException, InterruptedException {
CountingBodyPublisher bodyPublisher = new CountingBodyPublisher(BodyPublishers.ofFile(archivePath));
Builder builder = HttpRequest.newBuilder().uri(requestURI).method(method, bodyPublisher);
configureHttpRequest(builder);

long contentLength = bodyPublisher.contentLength();
getLog().debug(String.format("%s %s (%s)", method, requestURI,
UploadProgressReporter.formatBytes(contentLength)));
if (contentLength >= progressSettings.minimumSizeToAnnounceBytes()) {
getLog().info(String.format("Uploading model descriptor archive (%s), this can take a while on a slow "
+ "connection", UploadProgressReporter.formatBytes(contentLength)));
}
UploadProgressReporter reporter = new UploadProgressReporter(getLog(), progressSettings, contentLength,
bodyPublisher::getTransferredBytes);
return sendWithProgress(builder.build(), BodyHandlers.ofString(), reporter);
}

protected void validate(String type) {
super.validate();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package ai.timefold.solver.tools.maven.http;

import java.net.http.HttpRequest.BodyPublisher;
import java.nio.ByteBuffer;
import java.util.Objects;
import java.util.concurrent.Flow.Subscriber;
import java.util.concurrent.Flow.Subscription;
import java.util.concurrent.atomic.AtomicLong;

/**
* Wraps another {@link BodyPublisher} and counts the bytes handed over to the HTTP client's write pipeline, so that
* the thread waiting for the response can tell whether the request body is still moving.
* <p>
* The count is <em>not</em> the number of bytes acknowledged by the peer, it is the number of bytes the client has
* accepted for writing. The overshoot is bounded by the socket send buffer plus, for HTTP/2, the peer's flow control
* window, because the client only requests more buffers from this publisher once the previous ones have been written
* and the window allows more. On a slow connection that overshoot is a few hundred kilobytes at most and roughly
* constant, which makes the count a usable approximation of upload progress - and a slow connection is the only
* situation in which the count is ever reported.
*/
public final class CountingBodyPublisher implements BodyPublisher {

private final BodyPublisher delegate;
private final AtomicLong transferredBytes = new AtomicLong();

public CountingBodyPublisher(BodyPublisher delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
}

/**
* Delegated unchanged on purpose. Returning anything else (for example -1) makes the client fall back to chunked
* transfer encoding instead of sending a Content-Length header, which changes the request on the wire.
*/
@Override
public long contentLength() {
return delegate.contentLength();
}

/**
* Safe to call from any thread, in particular from the thread waiting for the response.
*/
public long getTransferredBytes() {
return transferredBytes.get();
}

@Override
public void subscribe(Subscriber<? super ByteBuffer> subscriber) {
Objects.requireNonNull(subscriber, "subscriber");
// The client may subscribe more than once for a single logical request, for example when it resends the
// request after a redirect. Every subscription restarts the body from the beginning, so the counter has to
// restart too, otherwise it runs past contentLength() and progress exceeds 100%.
transferredBytes.set(0);
delegate.subscribe(new CountingSubscriber(subscriber));
}

private final class CountingSubscriber implements Subscriber<ByteBuffer> {

private final Subscriber<? super ByteBuffer> downstream;

private CountingSubscriber(Subscriber<? super ByteBuffer> downstream) {
this.downstream = downstream;
}

@Override
public void onSubscribe(Subscription subscription) {
downstream.onSubscribe(subscription);
}

@Override
public void onNext(ByteBuffer item) {
// remaining() has to be read before forwarding: the downstream subscriber consumes the buffer, which
// advances its position, so remaining() is 0 by the time onNext returns. The counter is only updated
// after the forward, so a buffer rejected by a throwing subscriber is not counted.
int count = item.remaining();
downstream.onNext(item);
transferredBytes.addAndGet(count);
}

@Override
public void onError(Throwable throwable) {
downstream.onError(throwable);
}

@Override
public void onComplete() {
downstream.onComplete();
}
}

}
Loading
Loading