Skip to content

Commit 9309ef2

Browse files
authored
Merge branch 'main' into vabachu/prepare-v1.10.0-release
2 parents c00b013 + 0262c53 commit 9309ef2

19 files changed

Lines changed: 977 additions & 216 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
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+
* Recreate a deleted large-payload container and retry the upload once, without allowing stale concurrent failures to invalidate a newly recreated container.
6+
* Preserve backend timestamp precision in orchestration history and client metadata, including export blob names, while retaining existing orchestration replay timestamp behavior.
7+
* 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.
58
* Add normalized token audience configuration through Azure Managed client/worker options, convenience overloads, and connection-string `ResourceId`. Missing or empty audiences now default to `https://durabletask.azure.us` when `REGION_NAME` starts with `usgov` or `usdod` (case-insensitively), otherwise `https://durabletask.io`. Explicit audiences override this default; surrounding Unicode whitespace is trimmed, and whitespace-only or otherwise empty normalized values are rejected.
69
* Add optional connection-string `AuthorityHost` forwarding for Azure Identity credentials that support it. Audience selection does not change the service endpoint or credential authority; managed identity and developer-tool cloud configuration remain separate.
710
* Add the `exporthistory` module for durable, checkpointed export of terminal orchestration history to Azure Blob Storage ([#293](https://github.com/microsoft/durabletask-java/pull/293))
811
* Add client APIs to list terminal instance IDs by completion time (`listInstanceIds`) and read orchestration history (`getOrchestrationHistory`) ([#292](https://github.com/microsoft/durabletask-java/pull/292))
912
* Add `createReplaySafeLogger` to suppress orchestration log output during replay ([#295](https://github.com/microsoft/durabletask-java/pull/295))
10-
* Add `getParentInstance()` API to `TaskOrchestrationContext` for discovering parent orchestration info ([#284](https://github.com/microsoft/durabletask-java/pull/284))
13+
* Add `getParentInstance()` API to `TaskOrchestrationContext` for discovering parent orchestration info ([#284](https://github.com/microsoft/durabletask-java/pull/284))
1114
* Add the `azure-blob-payloads` module for externalizing large orchestration payloads to Azure Blob Storage ([#280](https://github.com/microsoft/durabletask-java/pull/280))
1215
* Add inner failure details and custom exception properties to `FailureDetails` ([#263](https://github.com/microsoft/durabletask-java/pull/263))
1316

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

Lines changed: 73 additions & 106 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.BlobErrorCode;
1112
import com.azure.storage.blob.models.BlobHttpHeaders;
1213
import com.azure.storage.blob.models.BlobRequestConditions;
1314
import com.azure.storage.blob.models.BlobStorageException;
@@ -20,9 +21,8 @@
2021
import java.io.InputStream;
2122
import java.nio.charset.StandardCharsets;
2223
import java.util.UUID;
23-
import java.util.concurrent.CountDownLatch;
24-
import java.util.concurrent.atomic.AtomicBoolean;
2524
import java.util.concurrent.atomic.AtomicReference;
25+
import java.util.concurrent.locks.ReentrantLock;
2626
import java.util.regex.Pattern;
2727
import java.util.zip.GZIPInputStream;
2828
import java.util.zip.GZIPOutputStream;
@@ -32,6 +32,7 @@
3232
* <p>
3333
* Stores payloads as blobs and returns opaque tokens in the form {@code blob:v1:<container>:<blobName>}.
3434
* Supports optional gzip compression. The blob container is created automatically on first upload.
35+
* If the container is subsequently deleted, a new upload recreates it and retries once.
3536
*/
3637
public final class BlobPayloadStore extends PayloadStore {
3738

@@ -48,12 +49,9 @@ public final class BlobPayloadStore extends PayloadStore {
4849
private final BlobContainerClient containerClient;
4950
private final LargePayloadStorageOptions options;
5051

51-
// Container-creation guard. The first thread to call ensureContainerExists() creates the
52-
// latch and performs the RPC. Concurrent callers await the latch so they don't race ahead
53-
// and upload to a container that hasn't been created yet. On failure the reference is
54-
// reset to null so a subsequent call can retry.
55-
private final AtomicReference<CountDownLatch> containerLatch = new AtomicReference<>();
56-
private volatile boolean containerVerified;
52+
private final ReentrantLock containerInitializationLock = new ReentrantLock();
53+
// A stale upload failure must not invalidate a container another upload has already recreated.
54+
private final AtomicReference<Object> containerGeneration = new AtomicReference<>();
5755

5856
/**
5957
* Creates a new {@code BlobPayloadStore} from the given options.
@@ -123,118 +121,87 @@ public String upload(String payload) {
123121

124122
byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);
125123

126-
// Ensure container exists before uploading. Thread-safe: the first caller creates
127-
// the container while concurrent callers wait for it to complete.
128-
ensureContainerExists();
129-
130-
try {
131-
// Defense-in-depth: require the blob to not already exist (If-None-Match: *).
132-
// Blob names are random UUIDs so collisions are astronomically unlikely, but this
133-
// guards against future regressions (e.g. a caller-supplied PayloadStore that
134-
// generates deterministic names or a refactor that reuses names) by failing loudly
135-
// instead of silently overwriting someone else's payload.
136-
BlobRequestConditions conditions = new BlobRequestConditions().setIfNoneMatch("*");
137-
if (this.options.isCompressionEnabled()) {
138-
ByteArrayOutputStream compressedBuffer = new ByteArrayOutputStream();
139-
try (GZIPOutputStream gzip = new GZIPOutputStream(compressedBuffer)) {
140-
gzip.write(payloadBytes);
141-
}
142-
byte[] compressedBytes = compressedBuffer.toByteArray();
143-
BlobHttpHeaders headers = new BlobHttpHeaders().setContentEncoding(CONTENT_ENCODING_GZIP);
144-
try (InputStream stream = new ByteArrayInputStream(compressedBytes)) {
145-
blob.uploadWithResponse(
146-
stream,
147-
compressedBytes.length,
148-
null, // parallelTransferOptions
149-
headers,
150-
null, // metadata
151-
null, // tier
152-
conditions, // requestConditions
153-
null, // timeout
154-
Context.NONE);
124+
boolean retryAfterContainerNotFound = true;
125+
while (true) {
126+
Object generation = ensureContainerExists();
127+
try {
128+
uploadBlob(blob, payloadBytes);
129+
return encodeToken(this.containerClient.getBlobContainerName(), blobName);
130+
} catch (BlobStorageException e) {
131+
if (retryAfterContainerNotFound
132+
&& e.getStatusCode() == 404
133+
&& BlobErrorCode.CONTAINER_NOT_FOUND.equals(e.getErrorCode())) {
134+
this.containerGeneration.compareAndSet(generation, null);
135+
retryAfterContainerNotFound = false;
136+
continue;
155137
}
156-
} else {
157-
try (InputStream stream = new ByteArrayInputStream(payloadBytes)) {
158-
blob.uploadWithResponse(
159-
stream,
160-
payloadBytes.length,
161-
null, // parallelTransferOptions
162-
null, // headers
163-
null, // metadata
164-
null, // tier
165-
conditions, // requestConditions
166-
null, // timeout
167-
Context.NONE);
138+
139+
if (e.getStatusCode() == 409 || e.getStatusCode() == 412) {
140+
throw new PayloadStorageException(
141+
"Payload blob '" + blobName + "' already exists in container '"
142+
+ this.containerClient.getBlobContainerName()
143+
+ "'. Refusing to overwrite. This should not happen with random UUID blob names "
144+
+ "and likely indicates a bug in a custom PayloadStore implementation.", e);
168145
}
146+
throw new PayloadStorageException("Failed to upload payload blob '" + blobName + "'.", e);
147+
} catch (IOException e) {
148+
throw new PayloadStorageException("Failed to upload payload blob '" + blobName + "'.", e);
169149
}
170-
} catch (BlobStorageException e) {
171-
// 409 BlobAlreadyExists and 412 ConditionNotMet (from If-None-Match: *) both indicate
172-
// a name collision on upload — treat as a hard failure rather than silently overwriting.
173-
if (e.getStatusCode() == 409 || e.getStatusCode() == 412) {
174-
throw new PayloadStorageException(
175-
"Payload blob '" + blobName + "' already exists in container '"
176-
+ this.containerClient.getBlobContainerName()
177-
+ "'. Refusing to overwrite. This should not happen with random UUID blob names "
178-
+ "and likely indicates a bug in a custom PayloadStore implementation.", e);
179-
}
180-
throw new PayloadStorageException("Failed to upload payload blob '" + blobName + "'.", e);
181-
} catch (IOException e) {
182-
throw new PayloadStorageException("Failed to upload payload blob '" + blobName + "'.", e);
183150
}
151+
}
184152

185-
return encodeToken(this.containerClient.getBlobContainerName(), blobName);
153+
private void uploadBlob(BlobClient blob, byte[] payloadBytes) throws IOException {
154+
BlobRequestConditions conditions = new BlobRequestConditions().setIfNoneMatch("*");
155+
if (this.options.isCompressionEnabled()) {
156+
ByteArrayOutputStream compressedBuffer = new ByteArrayOutputStream();
157+
try (GZIPOutputStream gzip = new GZIPOutputStream(compressedBuffer)) {
158+
gzip.write(payloadBytes);
159+
}
160+
byte[] compressedBytes = compressedBuffer.toByteArray();
161+
BlobHttpHeaders headers = new BlobHttpHeaders().setContentEncoding(CONTENT_ENCODING_GZIP);
162+
try (InputStream stream = new ByteArrayInputStream(compressedBytes)) {
163+
blob.uploadWithResponse(
164+
stream, compressedBytes.length, null, headers, null, null, conditions, null, Context.NONE);
165+
}
166+
} else {
167+
try (InputStream stream = new ByteArrayInputStream(payloadBytes)) {
168+
blob.uploadWithResponse(
169+
stream, payloadBytes.length, null, null, null, null, conditions, null, Context.NONE);
170+
}
171+
}
186172
}
187173

188-
/**
189-
* Ensures the blob container exists, creating it if necessary. Thread-safe: the first
190-
* caller performs the RPC while concurrent callers wait for it to complete. On success
191-
* the check is skipped on all future calls. On failure the guard is reset so a later
192-
* call can retry.
193-
*/
194-
private void ensureContainerExists() {
195-
if (this.containerVerified) {
196-
return;
174+
private Object ensureContainerExists() {
175+
Object generation = this.containerGeneration.get();
176+
if (generation != null) {
177+
return generation;
197178
}
198179

199-
CountDownLatch latch = new CountDownLatch(1);
200-
if (!this.containerLatch.compareAndSet(null, latch)) {
201-
CountDownLatch existing = this.containerLatch.get();
202-
if (existing == null) {
203-
// Rare race: the creating thread already reset the latch (failure path).
204-
// Retry on the next upload call rather than proceeding without a container.
205-
return;
206-
}
207-
// Another thread is already creating the container — wait for it.
208-
try {
209-
existing.await();
210-
} catch (InterruptedException e) {
211-
Thread.currentThread().interrupt();
212-
throw new PayloadStorageException("Interrupted while waiting for container creation.", e);
213-
}
214-
// If the creating thread failed, containerVerified is still false; the next
215-
// upload attempt will retry. For now, return and let the upload proceed
216-
// (it will fail fast with a clear error if the container doesn't exist).
217-
return;
180+
try {
181+
this.containerInitializationLock.lockInterruptibly();
182+
} catch (InterruptedException e) {
183+
Thread.currentThread().interrupt();
184+
throw new PayloadStorageException("Interrupted while waiting for container creation.", e);
218185
}
219186

220-
// This thread is responsible for creating the container.
221187
try {
222-
this.containerClient.createIfNotExists();
223-
this.containerVerified = true;
224-
} catch (BlobStorageException e) {
225-
if (e.getStatusCode() == 409) {
226-
// 409 Conflict means it already exists — safe to ignore.
227-
this.containerVerified = true;
228-
} else {
229-
this.containerLatch.set(null); // allow a future call to retry
230-
throw new PayloadStorageException(
231-
"Failed to create blob container '" + this.containerClient.getBlobContainerName() + "'.", e);
188+
generation = this.containerGeneration.get();
189+
if (generation == null) {
190+
try {
191+
this.containerClient.createIfNotExists();
192+
} catch (BlobStorageException e) {
193+
if (e.getStatusCode() != 409
194+
|| !BlobErrorCode.CONTAINER_ALREADY_EXISTS.equals(e.getErrorCode())) {
195+
throw new PayloadStorageException(
196+
"Failed to create blob container '" + this.containerClient.getBlobContainerName() + "'.", e);
197+
}
198+
}
199+
generation = new Object();
200+
this.containerGeneration.set(generation);
232201
}
233-
} catch (RuntimeException e) {
234-
this.containerLatch.set(null); // allow a future call to retry
235-
throw e;
202+
return generation;
236203
} finally {
237-
latch.countDown(); // unblock waiting threads
204+
this.containerInitializationLock.unlock();
238205
}
239206
}
240207

0 commit comments

Comments
 (0)