Skip to content

Commit fc9b8a1

Browse files
YunchuWangCopilot
andcommitted
Add blob payload auto-purge worker service to AzureBlobPayloads
Implements the worker/SDK side of large-payload blob auto-purge. The DTS backend soft-deletes blob-externalized payload rows and streams their tokens to the worker over a bidirectional gRPC stream; the worker deletes the backing Azure blobs immediately, acks, and the backend then hard-deletes the row. - PayloadStore: add virtual DeleteAsync (default throws NotSupportedException, so existing external subclasses keep compiling); BlobPayloadStore overrides it to decode the blob:v1: token and call DeleteIfExistsAsync (idempotent). - BlobPayloadAutoPurgeService: public BackgroundService that opens one bidi PurgeExternalPayloads stream, deletes + acks per tombstone, logs-and-continues on a bad token (no ack, so the backend can re-push), and reconnects with jittered backoff. - The service is independently constructable from a CallInvoker (or gRPC channel) + PayloadStore, so hosts that enable large payloads outside UseExternalizedPayloads (e.g. the DTS Azure Managed worker SDK) can reuse it. - Auto-registered from UseExternalizedPayloads for the OSS path (gated entirely on enable; resolves the worker's existing DTS CallInvoker lazily at start). - Proto: add PurgeExternalPayloads rpc + TombstonedPayload/PayloadPurged messages (authoritative change is a follow-up in microsoft/durabletask-protobuf). - Add BlobPayloadStore.DeleteAsync + BlobPayloadAutoPurgeService construction tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1f02d44 commit fc9b8a1

10 files changed

Lines changed: 581 additions & 1 deletion

File tree

Microsoft.DurableTask.sln

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
# Visual Studio Version 17
44
VisualStudioVersion = 17.3.32901.215
@@ -145,6 +145,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManage
145145
EndProject
146146
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{3B8F957E-7773-4C0C-ACD7-91A1591D9312}"
147147
EndProject
148+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureBlobPayloads.Tests", "test\AzureBlobPayloads.Tests\AzureBlobPayloads.Tests.csproj", "{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}"
149+
EndProject
148150
Global
149151
GlobalSection(SolutionConfigurationPlatforms) = preSolution
150152
Debug|Any CPU = Debug|Any CPU
@@ -839,6 +841,18 @@ Global
839841
{C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x64.Build.0 = Release|Any CPU
840842
{C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.ActiveCfg = Release|Any CPU
841843
{C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.Build.0 = Release|Any CPU
844+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
845+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
846+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|x64.ActiveCfg = Debug|Any CPU
847+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|x64.Build.0 = Debug|Any CPU
848+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|x86.ActiveCfg = Debug|Any CPU
849+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Debug|x86.Build.0 = Debug|Any CPU
850+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
851+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|Any CPU.Build.0 = Release|Any CPU
852+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|x64.ActiveCfg = Release|Any CPU
853+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|x64.Build.0 = Release|Any CPU
854+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|x86.ActiveCfg = Release|Any CPU
855+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5}.Release|x86.Build.0 = Release|Any CPU
842856
EndGlobalSection
843857
GlobalSection(SolutionProperties) = preSolution
844858
HideSolutionNode = FALSE
@@ -911,6 +925,7 @@ Global
911925
{C1995163-1DCE-405D-BE82-8B4B2584893E} = {9686B8F9-2644-6C9B-E567-55B0471E4584}
912926
{53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5}
913927
{3B8F957E-7773-4C0C-ACD7-91A1591D9312} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5}
928+
{531B29A0-B2AD-43E2-8F65-0BA85C82C4B5} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
914929
EndGlobalSection
915930
GlobalSection(ExtensibilityGlobals) = postSolution
916931
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Grpc.Core;
5+
using Microsoft.DurableTask.Worker.Grpc;
6+
using Microsoft.Extensions.Hosting;
7+
using Microsoft.Extensions.Logging;
8+
using Microsoft.Extensions.Logging.Abstractions;
9+
using Microsoft.Extensions.Options;
10+
using P = Microsoft.DurableTask.Protobuf;
11+
12+
namespace Microsoft.DurableTask;
13+
14+
/// <summary>
15+
/// Background service that drains blob-externalized payload tombstones streamed by the Durable Task
16+
/// backend and deletes the corresponding blobs from customer storage. The backend cannot delete these
17+
/// blobs itself because it has no storage credentials; it soft-deletes the payload row, pushes the token
18+
/// to this worker, and hard-deletes the row only once the worker acknowledges the blob has been removed.
19+
/// </summary>
20+
/// <remarks>
21+
/// <para>
22+
/// On the open-source worker this service is registered automatically by <c>UseExternalizedPayloads</c>
23+
/// and reuses that worker's gRPC connection. It can also be constructed directly from a
24+
/// <see cref="CallInvoker"/> (or gRPC channel) and a <see cref="PayloadStore"/> so hosts that enable large
25+
/// payloads through a different code path (for example the Durable Task Scheduler "Azure Managed" worker
26+
/// SDK) can reuse it without going through <c>UseExternalizedPayloads</c>. Because it derives from
27+
/// <see cref="BackgroundService"/> such hosts can start and stop it directly via
28+
/// <see cref="IHostedService.StartAsync"/> / <see cref="IHostedService.StopAsync"/>.
29+
/// </para>
30+
/// <para>
31+
/// It opens a single bidirectional gRPC stream and blocks awaiting server pushes when idle (a push model,
32+
/// not a polling loop) for the worker process lifetime. Deletes are idempotent, so running multiple worker
33+
/// replicas concurrently is safe.
34+
/// </para>
35+
/// </remarks>
36+
public sealed class BlobPayloadAutoPurgeService : BackgroundService
37+
{
38+
static readonly TimeSpan ReconnectBackoffBase = TimeSpan.FromSeconds(1);
39+
static readonly TimeSpan ReconnectBackoffCap = TimeSpan.FromSeconds(30);
40+
41+
readonly PayloadStore store;
42+
readonly ILogger logger;
43+
readonly Random jitter = new();
44+
45+
// Exactly one connection strategy is used. When a CallInvoker is supplied directly (cross-repo reuse) it
46+
// is used as-is; otherwise the OSS DI path resolves it lazily from the named worker options at start,
47+
// which keeps host startup resilient if the worker connection is misconfigured.
48+
readonly CallInvoker? callInvoker;
49+
readonly IOptionsMonitor<GrpcDurableTaskWorkerOptions>? workerOptions;
50+
readonly string? builderName;
51+
52+
/// <summary>
53+
/// Initializes a new instance of the <see cref="BlobPayloadAutoPurgeService"/> class that drains the
54+
/// purge stream over the supplied gRPC call invoker. Use this constructor to reuse the service from a
55+
/// host that does not enable large payloads through <c>UseExternalizedPayloads</c>.
56+
/// </summary>
57+
/// <param name="callInvoker">The gRPC call invoker connected to the Durable Task backend.</param>
58+
/// <param name="store">The payload store used to delete blobs.</param>
59+
/// <param name="logger">The logger, or <c>null</c> to disable logging.</param>
60+
public BlobPayloadAutoPurgeService(
61+
CallInvoker callInvoker,
62+
PayloadStore store,
63+
ILogger? logger = null)
64+
: this(store, logger)
65+
{
66+
this.callInvoker = Check.NotNull(callInvoker);
67+
}
68+
69+
/// <summary>
70+
/// Initializes a new instance of the <see cref="BlobPayloadAutoPurgeService"/> class that drains the
71+
/// purge stream over the supplied gRPC channel. Use this constructor to reuse the service from a host
72+
/// that does not enable large payloads through <c>UseExternalizedPayloads</c>.
73+
/// </summary>
74+
/// <param name="channel">The gRPC channel connected to the Durable Task backend.</param>
75+
/// <param name="store">The payload store used to delete blobs.</param>
76+
/// <param name="logger">The logger, or <c>null</c> to disable logging.</param>
77+
public BlobPayloadAutoPurgeService(
78+
ChannelBase channel,
79+
PayloadStore store,
80+
ILogger? logger = null)
81+
: this(Check.NotNull(channel).CreateCallInvoker(), store, logger)
82+
{
83+
}
84+
85+
/// <summary>
86+
/// Initializes a new instance of the <see cref="BlobPayloadAutoPurgeService"/> class that resolves its
87+
/// gRPC connection from the named worker options. Used by the <c>UseExternalizedPayloads</c> DI path.
88+
/// </summary>
89+
/// <param name="store">The payload store used to delete blobs.</param>
90+
/// <param name="workerOptions">Monitor for the named gRPC worker options that carry the DTS connection.</param>
91+
/// <param name="builderName">The Durable Task worker builder name whose options this service uses.</param>
92+
/// <param name="logger">The logger.</param>
93+
internal BlobPayloadAutoPurgeService(
94+
PayloadStore store,
95+
IOptionsMonitor<GrpcDurableTaskWorkerOptions> workerOptions,
96+
string builderName,
97+
ILogger<BlobPayloadAutoPurgeService> logger)
98+
: this(store, logger)
99+
{
100+
this.workerOptions = Check.NotNull(workerOptions);
101+
this.builderName = Check.NotNull(builderName);
102+
}
103+
104+
BlobPayloadAutoPurgeService(PayloadStore store, ILogger? logger)
105+
{
106+
this.store = Check.NotNull(store);
107+
this.logger = logger ?? NullLogger.Instance;
108+
}
109+
110+
/// <inheritdoc/>
111+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
112+
{
113+
CallInvoker? callInvoker = this.ResolveCallInvoker();
114+
if (callInvoker is null)
115+
{
116+
// ResolveCallInvoker already logged why the purge stream cannot run.
117+
return;
118+
}
119+
120+
P.TaskHubSidecarService.TaskHubSidecarServiceClient client = new(callInvoker);
121+
TimeSpan backoff = ReconnectBackoffBase;
122+
123+
while (!stoppingToken.IsCancellationRequested)
124+
{
125+
try
126+
{
127+
await this.DrainPurgeStreamAsync(client, stoppingToken);
128+
129+
// A clean server-side completion just means "nothing more to purge right now"; reconnect promptly.
130+
backoff = ReconnectBackoffBase;
131+
}
132+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
133+
{
134+
break;
135+
}
136+
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
137+
{
138+
Logs.AutoPurgeStreamError(this.logger, ex);
139+
await this.DelayWithJitterAsync(backoff, stoppingToken);
140+
backoff = NextBackoff(backoff);
141+
}
142+
}
143+
}
144+
145+
static TimeSpan NextBackoff(TimeSpan current)
146+
{
147+
if (current >= ReconnectBackoffCap)
148+
{
149+
return ReconnectBackoffCap;
150+
}
151+
152+
long doubledTicks = current.Ticks * 2;
153+
return doubledTicks >= ReconnectBackoffCap.Ticks ? ReconnectBackoffCap : TimeSpan.FromTicks(doubledTicks);
154+
}
155+
156+
CallInvoker? ResolveCallInvoker()
157+
{
158+
// Cross-repo reuse path: the caller supplied a ready-to-use connection.
159+
if (this.callInvoker is not null)
160+
{
161+
return this.callInvoker;
162+
}
163+
164+
// OSS DI path: reuse the same connection the worker uses to reach DTS. After UseExternalizedPayloads
165+
// runs, the named options are guaranteed to expose a CallInvoker (a Channel, if supplied, is folded
166+
// into one during PostConfigure).
167+
CallInvoker? resolved;
168+
try
169+
{
170+
resolved = this.workerOptions?.Get(this.builderName!).CallInvoker;
171+
}
172+
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
173+
{
174+
Logs.AutoPurgeConfigurationError(this.logger, ex);
175+
return null;
176+
}
177+
178+
if (resolved is null)
179+
{
180+
Logs.AutoPurgeNoCallInvoker(this.logger);
181+
}
182+
183+
return resolved;
184+
}
185+
186+
async Task DrainPurgeStreamAsync(
187+
P.TaskHubSidecarService.TaskHubSidecarServiceClient client, CancellationToken stoppingToken)
188+
{
189+
using AsyncDuplexStreamingCall<P.PayloadPurged, P.TombstonedPayload> call =
190+
client.PurgeExternalPayloads(cancellationToken: stoppingToken);
191+
192+
Logs.AutoPurgeStreamOpened(this.logger);
193+
194+
while (await call.ResponseStream.MoveNext(stoppingToken))
195+
{
196+
P.TombstonedPayload tombstone = call.ResponseStream.Current;
197+
try
198+
{
199+
await this.store.DeleteAsync(tombstone.Token, stoppingToken);
200+
}
201+
catch (Exception ex) when (
202+
ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException)
203+
{
204+
// Leave the payload soft-deleted so the backend can re-push it later; skip the ack.
205+
Logs.AutoPurgeDeleteFailed(this.logger, ex, tombstone.Token);
206+
continue;
207+
}
208+
209+
// The ack write inherits cancellation from the duplex call (opened with stoppingToken); the
210+
// WriteAsync(message, CancellationToken) overload is unavailable on netstandard2.0.
211+
#pragma warning disable CA2016 // Forward the CancellationToken parameter to methods that take one
212+
await call.RequestStream.WriteAsync(new P.PayloadPurged
213+
{
214+
PartitionId = tombstone.PartitionId,
215+
InstanceKey = tombstone.InstanceKey,
216+
PayloadId = tombstone.PayloadId,
217+
});
218+
#pragma warning restore CA2016
219+
}
220+
221+
await call.RequestStream.CompleteAsync();
222+
}
223+
224+
async Task DelayWithJitterAsync(TimeSpan maxDelay, CancellationToken cancellationToken)
225+
{
226+
// Full jitter in [0, maxDelay) spreads reconnect attempts across replicas.
227+
TimeSpan delay = TimeSpan.FromTicks((long)(maxDelay.Ticks * this.jitter.NextDouble()));
228+
if (delay > TimeSpan.Zero)
229+
{
230+
await Task.Delay(delay, cancellationToken);
231+
}
232+
}
233+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Microsoft.DurableTask;
7+
8+
/// <summary>
9+
/// Log messages for the Azure Blob externalized-payload auto-purge background service.
10+
/// </summary>
11+
static partial class Logs
12+
{
13+
[LoggerMessage(
14+
EventId = 800,
15+
Level = LogLevel.Debug,
16+
Message = "Blob payload auto-purge stream opened; awaiting tombstoned payloads from the backend.")]
17+
public static partial void AutoPurgeStreamOpened(ILogger logger);
18+
19+
[LoggerMessage(
20+
EventId = 801,
21+
Level = LogLevel.Warning,
22+
Message = "Blob payload auto-purge stream failed; reconnecting after backoff.")]
23+
public static partial void AutoPurgeStreamError(ILogger logger, Exception exception);
24+
25+
[LoggerMessage(
26+
EventId = 802,
27+
Level = LogLevel.Warning,
28+
Message = "Failed to delete externalized payload blob for token '{Token}'; leaving it for the backend to re-push.")]
29+
public static partial void AutoPurgeDeleteFailed(ILogger logger, Exception exception, string token);
30+
31+
[LoggerMessage(
32+
EventId = 803,
33+
Level = LogLevel.Warning,
34+
Message = "Blob payload auto-purge could not resolve a gRPC call invoker; the purge stream will not run.")]
35+
public static partial void AutoPurgeNoCallInvoker(ILogger logger);
36+
37+
[LoggerMessage(
38+
EventId = 804,
39+
Level = LogLevel.Error,
40+
Message = "Blob payload auto-purge failed to read the worker gRPC options; the purge stream will not run.")]
41+
public static partial void AutoPurgeConfigurationError(ILogger logger, Exception exception);
42+
}

src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using Microsoft.DurableTask.Worker;
88
using Microsoft.DurableTask.Worker.Grpc;
99
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.Extensions.Hosting;
11+
using Microsoft.Extensions.Logging;
1012
using Microsoft.Extensions.Options;
1113
using P = Microsoft.DurableTask.Protobuf;
1214

@@ -82,6 +84,20 @@ static IDurableTaskWorkerBuilder UseExternalizedPayloadsCore(IDurableTaskWorkerB
8284
opt.Capabilities.Add(P.WorkerCapability.LargePayloads);
8385
});
8486

87+
// Drain blob-externalized payload tombstones the backend streams for deletion. Gated on this enable
88+
// path: if UseExternalizedPayloads is never called, the service is never registered and never runs.
89+
builder.Services.AddSingleton<IHostedService>(
90+
sp => CreateAutoPurgeService(sp, builder.Name));
91+
8592
return builder;
8693
}
94+
95+
static BlobPayloadAutoPurgeService CreateAutoPurgeService(IServiceProvider services, string builderName)
96+
{
97+
return new BlobPayloadAutoPurgeService(
98+
services.GetRequiredService<PayloadStore>(),
99+
services.GetRequiredService<IOptionsMonitor<GrpcDurableTaskWorkerOptions>>(),
100+
builderName,
101+
services.GetRequiredService<ILogger<BlobPayloadAutoPurgeService>>());
102+
}
87103
}

src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ public BlobPayloadStore(LargePayloadStorageOptions options)
6767
this.containerClient = serviceClient.GetBlobContainerClient(options.ContainerName);
6868
}
6969

70+
/// <summary>
71+
/// Initializes a new instance of the <see cref="BlobPayloadStore"/> class using a caller-supplied
72+
/// container client. Intended for unit testing so a mocked <see cref="BlobContainerClient"/> can be injected.
73+
/// </summary>
74+
/// <param name="containerClient">The blob container client to use.</param>
75+
/// <param name="options">The options for the blob payload store.</param>
76+
internal BlobPayloadStore(BlobContainerClient containerClient, LargePayloadStorageOptions options)
77+
{
78+
this.containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient));
79+
this.options = options ?? throw new ArgumentNullException(nameof(options));
80+
}
81+
7082
/// <inheritdoc/>
7183
public override async Task<string> UploadAsync(string payLoad, CancellationToken cancellationToken)
7284
{
@@ -145,6 +157,25 @@ public override async Task<string> DownloadAsync(string token, CancellationToken
145157
}
146158
}
147159

160+
/// <inheritdoc/>
161+
public override async Task DeleteAsync(string token, CancellationToken cancellationToken)
162+
{
163+
(string container, string name) = DecodeToken(token);
164+
if (!string.Equals(container, this.containerClient.Name, StringComparison.Ordinal))
165+
{
166+
throw new ArgumentException("Token container does not match configured container.", nameof(token));
167+
}
168+
169+
BlobClient blob = this.containerClient.GetBlobClient(name);
170+
171+
// Idempotent by design: DeleteIfExistsAsync returns false (rather than throwing) when the blob is
172+
// already gone, so re-delivered tombstones and concurrent purges from multiple worker replicas are safe.
173+
await blob.DeleteIfExistsAsync(
174+
DeleteSnapshotsOption.IncludeSnapshots,
175+
conditions: null,
176+
cancellationToken);
177+
}
178+
148179
/// <inheritdoc/>
149180
public override bool IsKnownPayloadToken(string value)
150181
{

0 commit comments

Comments
 (0)