Skip to content

Commit 2c4e2df

Browse files
committed
refactor: Simplify async polling logic and improve task status handling
Simplified the async polling logic in `ConvertOperations` by replacing the custom scheduler with `CompletionStage` chaining. Improved task status handling by centralizing logic for success, failure, and retry behavior. Cleaned up imports and adjusted tests to maintain consistency with updated logic.
1 parent 33f4cea commit 2c4e2df

4 files changed

Lines changed: 167 additions & 153 deletions

File tree

docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package ai.docling.serve.api;
22

3+
import java.time.Duration;
4+
35
import org.jspecify.annotations.Nullable;
46

7+
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
8+
59
/**
610
* Docling Serve API interface.
711
*/
@@ -104,6 +108,30 @@ default B prettyPrint() {
104108
return prettyPrint(true);
105109
}
106110

111+
/**
112+
* Sets the polling interval for async operations.
113+
*
114+
* <p>This configures how frequently the client will check the status of async
115+
* conversion tasks when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
116+
*
117+
* @param asyncPollInterval the polling interval (must not be null or negative)
118+
* @return this builder instance for method chaining
119+
* @throws IllegalArgumentException if asyncPollInterval is null or negative
120+
*/
121+
B asyncPollInterval(Duration asyncPollInterval);
122+
123+
/**
124+
* Sets the timeout for async operations.
125+
*
126+
* <p>This configures the maximum time to wait for an async conversion task to complete
127+
* when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
128+
*
129+
* @param asyncTimeout the timeout duration (must not be null or negative)
130+
* @return this builder instance for method chaining
131+
* @throws IllegalArgumentException if asyncTimeout is null or negative
132+
*/
133+
B asyncTimeout(Duration asyncTimeout);
134+
107135
/**
108136
* Builds and returns an instance of the specified type, representing the completed configuration
109137
* of the builder. The returned instance is typically an implementation of the Docling API.

docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55

66
import java.net.URI;
77
import java.net.http.HttpClient;
8-
import java.time.Duration;
98
import java.net.http.HttpClient.Redirect;
109
import java.net.http.HttpRequest;
1110
import java.net.http.HttpRequest.BodyPublisher;
1211
import java.net.http.HttpRequest.BodyPublishers;
1312
import java.net.http.HttpResponse;
1413
import java.net.http.HttpResponse.BodyHandlers;
1514
import java.nio.ByteBuffer;
15+
import java.time.Duration;
1616
import java.util.List;
1717
import java.util.Map;
1818
import java.util.Objects;
@@ -449,16 +449,18 @@ public B prettyPrint(boolean prettyPrint) {
449449
* Sets the polling interval for async operations.
450450
*
451451
* <p>This configures how frequently the client will check the status of async
452-
* conversion tasks when using {@link DoclingServeApi#convertSourceAsyncAndWait(ConvertDocumentRequest)}.
452+
* conversion tasks when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
453453
*
454454
* @param asyncPollInterval the polling interval (must not be null or negative)
455455
* @return this builder instance for method chaining
456456
* @throws IllegalArgumentException if asyncPollInterval is null or negative
457457
*/
458+
@Override
458459
public B asyncPollInterval(Duration asyncPollInterval) {
459460
if (asyncPollInterval == null || asyncPollInterval.isNegative() || asyncPollInterval.isZero()) {
460461
throw new IllegalArgumentException("asyncPollInterval must be a positive duration");
461462
}
463+
462464
this.asyncPollInterval = asyncPollInterval;
463465
return (B) this;
464466
}
@@ -467,12 +469,13 @@ public B asyncPollInterval(Duration asyncPollInterval) {
467469
* Sets the timeout for async operations.
468470
*
469471
* <p>This configures the maximum time to wait for an async conversion task to complete
470-
* when using {@link DoclingServeApi#convertSourceAsyncAndWait(ConvertDocumentRequest)}.
472+
* when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
471473
*
472474
* @param asyncTimeout the timeout duration (must not be null or negative)
473475
* @return this builder instance for method chaining
474476
* @throws IllegalArgumentException if asyncTimeout is null or negative
475477
*/
478+
@Override
476479
public B asyncTimeout(Duration asyncTimeout) {
477480
if (asyncTimeout == null || asyncTimeout.isNegative() || asyncTimeout.isZero()) {
478481
throw new IllegalArgumentException("asyncTimeout must be a positive duration");

docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/ConvertOperations.java

Lines changed: 60 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@
22

33
import java.time.Duration;
44
import java.util.concurrent.CompletableFuture;
5-
import java.util.concurrent.Executors;
6-
import java.util.concurrent.ScheduledExecutorService;
5+
import java.util.concurrent.CompletionStage;
76
import java.util.concurrent.TimeUnit;
87

98
import org.slf4j.Logger;
109
import org.slf4j.LoggerFactory;
1110

1211
import ai.docling.serve.api.DoclingServeConvertApi;
12+
import ai.docling.serve.api.DoclingServeTaskApi;
1313
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
1414
import ai.docling.serve.api.convert.response.ConvertDocumentResponse;
1515
import ai.docling.serve.api.task.request.TaskResultRequest;
1616
import ai.docling.serve.api.task.request.TaskStatusPollRequest;
17-
import ai.docling.serve.api.task.response.TaskStatus;
1817
import ai.docling.serve.api.task.response.TaskStatusPollResponse;
1918
import ai.docling.serve.api.util.ValidationUtils;
2019

@@ -26,22 +25,22 @@ public final class ConvertOperations implements DoclingServeConvertApi {
2625
private static final Logger LOG = LoggerFactory.getLogger(ConvertOperations.class);
2726

2827
private final HttpOperations httpOperations;
29-
private final TaskOperations taskOperations;
28+
private final DoclingServeTaskApi taskApi;
3029
private final Duration asyncPollInterval;
3130
private final Duration asyncTimeout;
3231

3332
/**
3433
* Creates a new ConvertOperations instance.
3534
*
3635
* @param httpOperations the HTTP operations handler for executing requests
37-
* @param taskOperations the task operations handler for polling and retrieving results
36+
* @param taskApi the task operations handler for polling and retrieving results
3837
* @param asyncPollInterval the interval between status polls for async operations
3938
* @param asyncTimeout the maximum time to wait for async operations to complete
4039
*/
41-
public ConvertOperations(HttpOperations httpOperations, TaskOperations taskOperations,
40+
public ConvertOperations(HttpOperations httpOperations, DoclingServeTaskApi taskApi,
4241
Duration asyncPollInterval, Duration asyncTimeout) {
4342
this.httpOperations = httpOperations;
44-
this.taskOperations = taskOperations;
43+
this.taskApi = taskApi;
4544
this.asyncPollInterval = asyncPollInterval;
4645
this.asyncTimeout = asyncTimeout;
4746
}
@@ -68,85 +67,69 @@ private <I> RequestContext<I, TaskStatusPollResponse> createAsyncRequestContext(
6867
.build();
6968
}
7069

71-
@Override
70+
@Override
7271
public CompletableFuture<ConvertDocumentResponse> convertSourceAsync(ConvertDocumentRequest request) {
7372
ValidationUtils.ensureNotNull(request, "request");
7473

75-
CompletableFuture<ConvertDocumentResponse> resultFuture = new CompletableFuture<>();
76-
77-
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
78-
Thread t = new Thread(r, "docling-async-poller");
79-
t.setDaemon(true);
80-
return t;
81-
});
82-
83-
// Start the async conversion
84-
CompletableFuture.supplyAsync(() ->
74+
// Start the async conversion and chain the polling logic
75+
return CompletableFuture.supplyAsync(() ->
8576
this.httpOperations.executePost(createAsyncRequestContext("/v1/convert/source/async", request))
86-
).thenAccept(taskResponse -> {
77+
).thenCompose(taskResponse -> {
8778
var taskId = taskResponse.getTaskId();
8879
LOG.info("Started async conversion with task ID: {}", taskId);
8980

9081
long startTime = System.currentTimeMillis();
91-
92-
// Schedule the polling task
93-
Runnable pollTask = new Runnable() {
94-
@Override
95-
public void run() {
96-
try {
97-
// Check if we've timed out
98-
if (System.currentTimeMillis() - startTime > asyncTimeout.toMillis()) {
99-
resultFuture.completeExceptionally(
100-
new RuntimeException("Async conversion timed out after " + asyncTimeout + " for task: " + taskId));
101-
scheduler.shutdown();
102-
return;
103-
}
104-
105-
// Use the existing pollTaskStatus method from TaskOperations
106-
var pollRequest = TaskStatusPollRequest.builder().taskId(taskId).build();
107-
var statusResponse = taskOperations.pollTaskStatus(pollRequest);
108-
var status = statusResponse.getTaskStatus();
109-
110-
LOG.debug("Task {} status: {}", taskId, status);
111-
112-
if (status == TaskStatus.SUCCESS) {
113-
LOG.info("Task {} completed successfully", taskId);
114-
// Use the existing convertTaskResult method from TaskOperations
115-
var resultRequest = TaskResultRequest.builder().taskId(taskId).build();
116-
try {
117-
var result = taskOperations.convertTaskResult(resultRequest);
118-
resultFuture.complete(result);
119-
} catch (Exception e) {
120-
resultFuture.completeExceptionally(e);
121-
}
122-
scheduler.shutdown();
123-
} else if (status == TaskStatus.FAILURE) {
124-
String errorMessage = "Task failed";
125-
if (statusResponse.getTaskStatusMetadata() != null) {
126-
errorMessage = "Task failed: " + statusResponse.getTaskStatusMetadata();
127-
}
128-
resultFuture.completeExceptionally(
129-
new RuntimeException("Async conversion failed for task " + taskId + ": " + errorMessage));
130-
scheduler.shutdown();
131-
} else {
132-
// Still in progress (PENDING or STARTED), schedule next poll
133-
scheduler.schedule(this, asyncPollInterval.toMillis(), TimeUnit.MILLISECONDS);
134-
}
135-
} catch (Exception e) {
136-
resultFuture.completeExceptionally(e);
137-
scheduler.shutdown();
138-
}
139-
}
140-
};
141-
142-
// Start polling immediately
143-
scheduler.schedule(pollTask, 0, TimeUnit.MILLISECONDS);
144-
}).exceptionally(e -> {
145-
resultFuture.completeExceptionally(e);
146-
scheduler.shutdown();
147-
return null;
82+
return pollTaskUntilComplete(taskId, startTime);
14883
});
84+
}
14985

150-
return resultFuture;
86+
/**
87+
* Recursively polls a task until it completes, fails, or times out.
88+
*
89+
* @param taskId the ID of the task to poll
90+
* @param startTime the timestamp when polling started (for timeout calculation)
91+
* @return a CompletableFuture that completes with the conversion result
92+
*/
93+
private CompletableFuture<ConvertDocumentResponse> pollTaskUntilComplete(String taskId, long startTime) {
94+
// Check if we've timed out
95+
if (System.currentTimeMillis() - startTime > asyncTimeout.toMillis()) {
96+
return CompletableFuture.failedFuture(
97+
new RuntimeException("Async conversion timed out after " + asyncTimeout + " for task: " + taskId));
98+
}
99+
100+
// Poll the task status
101+
var pollRequest = TaskStatusPollRequest.builder().taskId(taskId).build();
102+
103+
return CompletableFuture.supplyAsync(() -> taskApi.pollTaskStatus(pollRequest))
104+
.thenCompose(statusResponse -> pollTaskStatus(statusResponse, startTime));
105+
}
106+
107+
private CompletionStage<ConvertDocumentResponse> pollTaskStatus(TaskStatusPollResponse statusResponse, long startTime) {
108+
var status = statusResponse.getTaskStatus();
109+
var taskId = statusResponse.getTaskId();
110+
LOG.debug("Task {} status: {}", taskId, status);
111+
112+
return switch (statusResponse.getTaskStatus()) {
113+
case SUCCESS -> {
114+
LOG.info("Task {} completed successfully", taskId);
115+
// Retrieve the result
116+
var resultRequest = TaskResultRequest.builder().taskId(taskId).build();
117+
yield CompletableFuture.supplyAsync(() -> taskApi.convertTaskResult(resultRequest));
118+
}
119+
case FAILURE -> {
120+
var errorMessage = "Task failed";
121+
if (statusResponse.getTaskStatusMetadata()!=null) {
122+
errorMessage = "Task failed: " + statusResponse.getTaskStatusMetadata();
123+
}
124+
yield CompletableFuture.failedFuture(
125+
new RuntimeException("Async conversion failed for task " + taskId + ": " + errorMessage));
126+
}
127+
default ->
128+
// Still in progress (PENDING or STARTED), schedule next poll after delay
129+
CompletableFuture.supplyAsync(
130+
() -> null,
131+
CompletableFuture.delayedExecutor(asyncPollInterval.toMillis(), TimeUnit.MILLISECONDS)
132+
).thenCompose(v -> pollTaskUntilComplete(taskId, startTime));
133+
};
151134
}
152135
}

0 commit comments

Comments
 (0)