Skip to content

Commit 486df93

Browse files
committed
Merge main and resolve v1.10.0 release conflicts
2 parents 8880c5f + d040b7c commit 486df93

31 files changed

Lines changed: 1670 additions & 97 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## v1.10.0
44
* Enable custom exception providers for Durable Functions Java activities ([#294](https://github.com/microsoft/durabletask-java/pull/294))
5+
* Recover verified ambiguous payload uploads on HTTP 409 `BlobAlreadyExists` as well as HTTP 412, while rejecting unrelated conflicts.
6+
* Change the default large-payload externalization threshold from 900,000 bytes to 256 KiB, matching .NET.
7+
* Align the large-payload worker estimator threshold with the 256 KiB storage default, resolve externalized entity-operation failure traces, recover verified ambiguous blob uploads, and recognize `$root` payload tokens.
8+
* Preserve transient verification-download failures during ambiguous blob-upload recovery instead of reporting the original permanent precondition failure.
9+
* Propagate transient large-payload upload failures without completing work items; abandon failed activity and orchestration completions for scheduler redelivery instead of terminating the worker polling thread.
10+
* Ensure the export container exists before every upload, allowing export retries to recover containers deleted between uploads.
11+
* Preserve custom and empty property objects in exported failure details, including nested failures, matching .NET's history conversion.
12+
* Capture the default continuous-export completion-time lower bound when creation options are constructed, matching .NET and preserving the window through worker queue delays.
513
* Recreate a deleted large-payload container and retry the upload once, without allowing stale concurrent failures to invalidate a newly recreated container.
614
* Preserve backend timestamp precision in orchestration history and client metadata, including export blob names, while retaining existing orchestration replay timestamp behavior.
715
* Export entity operations and locks using the .NET-compatible `EventSent`/`EventRaised` message representation instead of Java-native entity event objects, preserving missing stack traces in failure details.

‎azure-blob-payloads/README.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Configure the same storage location for every client and worker that exchanges e
2020
LargePayloadStorageOptions payloadOptions = new LargePayloadStorageOptions()
2121
.setConnectionString(System.getenv("PAYLOAD_STORAGE_CONNECTION_STRING"))
2222
.setContainerName("durabletask-payloads")
23-
.setThresholdBytes(900_000);
23+
.setThresholdBytes(256 * 1024);
2424

2525
PayloadStore payloadStore = new BlobPayloadStore(payloadOptions);
2626

@@ -43,7 +43,7 @@ LargePayloadStorageOptions payloadOptions = new LargePayloadStorageOptions()
4343

4444
## Defaults and limits
4545

46-
- Payloads of at least 900,000 bytes are externalized by default.
46+
- Payloads of at least 256 KiB (262,144 bytes) are externalized by default.
4747
- The threshold can be configured up to 1 MiB.
4848
- The default maximum externalized payload size is 10 MiB.
4949
- Payloads are gzip-compressed by default.
@@ -52,6 +52,44 @@ LargePayloadStorageOptions payloadOptions = new LargePayloadStorageOptions()
5252

5353
The storage identity needs permission to create the container and read and write blobs. Payload blobs are retained after orchestration processing, so configure an Azure Storage lifecycle policy appropriate for the application's retention requirements.
5454

55+
## Upload failures
56+
57+
Transient upload failures (including HTTP 408, 429, and 5xx responses) propagate without sending a worker
58+
completion, allowing the work item to be retried rather than recording a task failure. Permanent activity and
59+
orchestration upload failures become non-retriable failure completions. Entity upload failures propagate without
60+
completing the batch, matching .NET. The worker logs failed completions and abandons the affected work item for
61+
scheduler redelivery without terminating its polling thread.
62+
63+
Uploads use conditional creation to prevent overwriting an existing payload. If a PUT commits but its response is
64+
lost, a retried PUT can return HTTP 412 or HTTP 409 `BlobAlreadyExists`. Other HTTP 409 errors are not recovered.
65+
The store recovers only after verifying that the stored bytes and content
66+
encoding exactly match the attempted upload. A verification download failure propagates with its own status so
67+
transient failures remain retryable. Payload tokens also support Azure's `$root` container.
68+
69+
## Offline regression tests
70+
71+
```text
72+
./gradlew :azure-blob-payloads:test :exporthistory:test :client:test -PskipSigning
73+
```
74+
75+
These tests do not require Azure or emulators. The blob-upload regressions run the real Azure SDK against an
76+
in-memory HTTP transport that commits a PUT and loses its response, exercising SDK retries with compression on
77+
and off. In-process gRPC tests cover `$root` tokens through client, worker, and streamed history paths and resolved
78+
nested failure traces in decoded JSON and JSONL exports. An orchestration replay regression also verifies that
79+
entity callers receive the original nested failure diagnostics after work-item payload resolution.
80+
81+
## Integration tests
82+
83+
With the DTS emulator on port 4001 and Azurite Blob Storage on port 10000, run:
84+
85+
```text
86+
./gradlew :azure-blob-payloads:integrationTest -PskipSigning
87+
```
88+
89+
The suite exercises payload round trips, activity and sub-orchestration outputs, events, queries, custom status,
90+
and payload limits. It also injects a transient worker upload failure and verifies actual scheduler redelivery
91+
and successful completion for both activity and orchestration outputs.
92+
5593
## Sample
5694

5795
See [`LargePayloadSample`](../samples/src/main/java/io/durabletask/samples/LargePayloadSample.java):

‎azure-blob-payloads/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies {
4747

4848
testImplementation 'org.mockito:mockito-core:5.21.0'
4949
testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0'
50+
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
5051
testImplementation project(':azuremanaged')
5152
}
5253

‎azure-blob-payloads/src/main/java/com/microsoft/durabletask/azureblobpayloads/BlobPayloadStore.java‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.azure.storage.blob.BlobServiceClient;
99
import com.azure.storage.blob.BlobServiceClientBuilder;
1010
import com.azure.storage.blob.models.BlobDownloadResponse;
11+
import com.azure.storage.blob.models.BlobDownloadHeaders;
1112
import com.azure.storage.blob.models.BlobErrorCode;
1213
import com.azure.storage.blob.models.BlobHttpHeaders;
1314
import com.azure.storage.blob.models.BlobRequestConditions;
@@ -20,6 +21,7 @@
2021
import java.io.IOException;
2122
import java.io.InputStream;
2223
import java.nio.charset.StandardCharsets;
24+
import java.util.Arrays;
2325
import java.util.UUID;
2426
import java.util.concurrent.atomic.AtomicReference;
2527
import java.util.concurrent.locks.ReentrantLock;
@@ -41,10 +43,10 @@ public final class BlobPayloadStore extends PayloadStore {
4143

4244
// Blob name is UUID.randomUUID().toString().replace("-", ""): exactly 32 lowercase hex chars.
4345
// Container name follows Azure rules: 3-63 chars, lowercase alphanumerics and single hyphens,
44-
// must start and end with alphanumeric (see isValidContainerName).
46+
// must start and end with alphanumeric, or the special $root container.
4547
// Full token grammar: blob:v1:<container>:<32-lowercase-hex>
4648
private static final Pattern TOKEN_PATTERN = Pattern.compile(
47-
"^blob:v1:[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,61}[a-z0-9]:[0-9a-f]{32}$");
49+
"^blob:v1:(?:\\$root|[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,61}[a-z0-9]):[0-9a-f]{32}$");
4850

4951
private final BlobContainerClient containerClient;
5052
private final LargePayloadStorageOptions options;
@@ -160,17 +162,60 @@ private void uploadBlob(BlobClient blob, byte[] payloadBytes) throws IOException
160162
byte[] compressedBytes = compressedBuffer.toByteArray();
161163
BlobHttpHeaders headers = new BlobHttpHeaders().setContentEncoding(CONTENT_ENCODING_GZIP);
162164
try (InputStream stream = new ByteArrayInputStream(compressedBytes)) {
163-
blob.uploadWithResponse(
164-
stream, compressedBytes.length, null, headers, null, null, conditions, null, Context.NONE);
165+
try {
166+
blob.uploadWithResponse(
167+
stream, compressedBytes.length, null, headers, null, null, conditions, null, Context.NONE);
168+
} catch (BlobStorageException e) {
169+
if (isPossibleCommittedUpload(e) && blobContentMatches(blob, compressedBytes, CONTENT_ENCODING_GZIP)) {
170+
return;
171+
}
172+
throw e;
173+
}
165174
}
166175
} else {
167176
try (InputStream stream = new ByteArrayInputStream(payloadBytes)) {
168-
blob.uploadWithResponse(
169-
stream, payloadBytes.length, null, null, null, null, conditions, null, Context.NONE);
177+
try {
178+
blob.uploadWithResponse(
179+
stream, payloadBytes.length, null, null, null, null, conditions, null, Context.NONE);
180+
} catch (BlobStorageException e) {
181+
if (isPossibleCommittedUpload(e) && blobContentMatches(blob, payloadBytes, null)) {
182+
return;
183+
}
184+
throw e;
185+
}
170186
}
171187
}
172188
}
173189

190+
private static boolean isPossibleCommittedUpload(BlobStorageException e) {
191+
return e.getStatusCode() == 412
192+
|| (e.getStatusCode() == 409 && BlobErrorCode.BLOB_ALREADY_EXISTS.equals(e.getErrorCode()));
193+
}
194+
195+
private static boolean blobContentMatches(BlobClient blob, byte[] expectedBytes, String expectedContentEncoding) {
196+
// Verification failures must propagate with their own status/cause. In particular,
197+
// a transient GET failure must not turn the ambiguous PUT into a permanent conflict.
198+
ByteArrayOutputStream output = new ByteArrayOutputStream();
199+
BlobDownloadResponse response = blob.downloadStreamWithResponse(
200+
output,
201+
null,
202+
null,
203+
null,
204+
false,
205+
null,
206+
Context.NONE);
207+
BlobDownloadHeaders headers = response.getDeserializedHeaders();
208+
String actualEncoding = headers == null ? null : headers.getContentEncoding();
209+
if (expectedContentEncoding == null) {
210+
if (actualEncoding != null && !actualEncoding.isEmpty()) {
211+
return false;
212+
}
213+
} else if (!expectedContentEncoding.equalsIgnoreCase(actualEncoding)) {
214+
return false;
215+
}
216+
return Arrays.equals(expectedBytes, output.toByteArray());
217+
}
218+
174219
private Object ensureContainerExists() {
175220
Object generation = this.containerGeneration.get();
176221
if (generation != null) {

‎azure-blob-payloads/src/main/java/com/microsoft/durabletask/azureblobpayloads/LargePayloadInterceptor.java‎

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -192,16 +192,17 @@ private Object externalizeActivityResponse(ActivityResponse r) {
192192
? "Permanent payload storage failure"
193193
: "Transient payload storage failure";
194194
logger.log(Level.WARNING, prefix + " while externalizing activity response.", ex);
195-
// Convert to a failure response so the orchestration sees a failed activity.
196-
// Permanent failures are non-retriable; transient failures are retriable so
197-
// the sidecar can re-dispatch the work item.
195+
if (!permanent) {
196+
throw ex;
197+
}
198+
// Only permanent failures complete the activity; transient failures leave it uncompleted.
198199
return r.toBuilder()
199200
.clearResult()
200201
.setFailureDetails(TaskFailureDetails.newBuilder()
201202
.setErrorType(ex.getClass().getName())
202203
.setErrorMessage(prefix + ": " + ex.getMessage())
203204
.setStackTrace(StringValue.of(getStackTraceString(ex)))
204-
.setIsNonRetriable(permanent)
205+
.setIsNonRetriable(true)
205206
.build())
206207
.build();
207208
}
@@ -235,9 +236,10 @@ private Object externalizeOrchestratorResponse(OrchestratorResponse r) {
235236
? "Permanent payload storage failure"
236237
: "Transient payload storage failure";
237238
logger.log(Level.WARNING, prefix + " while externalizing orchestrator response.", ex);
238-
// Replace with a single Failed completion.
239-
// Permanent failures are non-retriable; transient failures are retriable so
240-
// the sidecar can re-dispatch the orchestration.
239+
if (!permanent) {
240+
throw ex;
241+
}
242+
// Only permanent failures replace the actions with a terminal failed completion.
241243
return OrchestratorResponse.newBuilder()
242244
.setInstanceId(r.getInstanceId())
243245
.setCompletionToken(r.getCompletionToken())
@@ -249,7 +251,7 @@ private Object externalizeOrchestratorResponse(OrchestratorResponse r) {
249251
.setErrorType(ex.getClass().getName())
250252
.setErrorMessage(prefix + ": " + ex.getMessage())
251253
.setStackTrace(StringValue.of(getStackTraceString(ex)))
252-
.setIsNonRetriable(permanent)
254+
.setIsNonRetriable(true)
253255
.build())
254256
.build())
255257
.build())
@@ -438,18 +440,8 @@ private Object externalizeEntityBatchResult(EntityBatchResult r) {
438440
? "Permanent payload storage failure"
439441
: "Transient payload storage failure";
440442
logger.log(Level.WARNING, prefix + " while externalizing entity batch result.", ex);
441-
// Convert to a failure result so the sidecar records the entity failure.
442-
// Permanent failures are non-retriable; transient failures are retriable so
443-
// the sidecar can re-dispatch the entity work item.
444-
return EntityBatchResult.newBuilder()
445-
.setCompletionToken(r.getCompletionToken())
446-
.setFailureDetails(TaskFailureDetails.newBuilder()
447-
.setErrorType(ex.getClass().getName())
448-
.setErrorMessage(prefix + ": " + ex.getMessage())
449-
.setStackTrace(StringValue.of(getStackTraceString(ex)))
450-
.setIsNonRetriable(permanent)
451-
.build())
452-
.build();
443+
// .NET propagates all entity upload failures, leaving the batch uncompleted.
444+
throw ex;
453445
}
454446
}
455447

@@ -855,6 +847,16 @@ private HistoryEvent resolveEventPayloads(HistoryEvent e) {
855847
}
856848
}
857849
break;
850+
case ENTITYOPERATIONFAILED:
851+
if (e.hasEntityOperationFailed() && e.getEntityOperationFailed().hasFailureDetails()) {
852+
EntityOperationFailedEvent efailed = e.getEntityOperationFailed();
853+
TaskFailureDetails fd = resolveFailureDetails(efailed.getFailureDetails());
854+
if (fd != efailed.getFailureDetails()) {
855+
return e.toBuilder().setEntityOperationFailed(
856+
efailed.toBuilder().setFailureDetails(fd)).build();
857+
}
858+
}
859+
break;
858860
case HISTORYSTATE:
859861
if (e.hasHistoryState()) {
860862
HistoryStateEvent hs = e.getHistoryState();

‎azure-blob-payloads/src/main/java/com/microsoft/durabletask/azureblobpayloads/LargePayloadStorageOptions.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public final class LargePayloadStorageOptions {
3131

3232
private static final int ONE_MIB = 1024 * 1024;
3333

34-
private int thresholdBytes = 900_000;
34+
private int thresholdBytes = 256 * 1024;
3535
private int maxPayloadBytes = 10 * ONE_MIB;
3636
private String connectionString;
3737
private URI accountUri;
@@ -41,7 +41,7 @@ public final class LargePayloadStorageOptions {
4141

4242
/**
4343
* Gets the threshold in bytes at which payloads are externalized to blob storage.
44-
* Payloads smaller than this threshold are sent inline. Default is 900,000 bytes.
44+
* Payloads smaller than this threshold are sent inline. Default is 256 KiB (262,144 bytes).
4545
*
4646
* @return the threshold in bytes
4747
*/

0 commit comments

Comments
 (0)