-
Notifications
You must be signed in to change notification settings - Fork 58
Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) #758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| namespace Microsoft.DurableTask.Client; | ||
|
|
||
| /// <summary> | ||
| /// Serializable acknowledgement that the worker has deleted the blob for a tombstoned payload, so the | ||
| /// backend can hard-delete the soft-deleted row. Mirrors the <c>PayloadPurgeAck</c> protobuf message. | ||
| /// </summary> | ||
| /// <param name="PartitionId">The backend partition that owns the payload row.</param> | ||
| /// <param name="InstanceKey">The orchestration instance key the payload belongs to.</param> | ||
| /// <param name="PayloadId">The backend identifier of the soft-deleted payload row.</param> | ||
| public sealed record PayloadPurgeAck(int PartitionId, long InstanceKey, long PayloadId); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| namespace Microsoft.DurableTask.Client; | ||
|
|
||
| /// <summary> | ||
| /// Serializable representation of a tombstoned payload the backend has soft-deleted and whose blob the | ||
| /// worker should delete. Mirrors the <c>TombstonedPayload</c> protobuf message but is safe to pass through | ||
| /// the orchestration/activity boundary. | ||
| /// </summary> | ||
| /// <param name="PartitionId">The backend partition that owns the payload row.</param> | ||
| /// <param name="InstanceKey">The orchestration instance key the payload belongs to.</param> | ||
| /// <param name="PayloadId">The backend identifier of the soft-deleted payload row.</param> | ||
| /// <param name="Token">The externalized payload token whose backing blob should be deleted.</param> | ||
| public sealed record TombstonedPayload(int PartitionId, long InstanceKey, long PayloadId, string Token); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Microsoft.DurableTask.Client; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Microsoft.DurableTask.AzureBlobPayloads; | ||
|
|
||
| /// <summary> | ||
| /// Activity that acknowledges to the backend the payloads whose blobs the worker has deleted, so the backend | ||
| /// can hard-delete the soft-deleted rows. | ||
| /// </summary> | ||
| /// <param name="client">The Durable Task client used to acknowledge purged payloads to the backend.</param> | ||
| /// <param name="logger">The logger instance.</param> | ||
| [DurableTask] | ||
| public class AckPurgedPayloadsActivity( | ||
| DurableTaskClient client, | ||
| ILogger<AckPurgedPayloadsActivity> logger) | ||
| : TaskActivity<List<PayloadPurgeAck>, object?> | ||
| { | ||
| readonly DurableTaskClient client = Check.NotNull(client); | ||
| readonly ILogger<AckPurgedPayloadsActivity> logger = Check.NotNull(logger); | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async Task<object?> RunAsync(TaskActivityContext context, List<PayloadPurgeAck> input) | ||
| { | ||
| if (input is null || input.Count == 0) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| await this.client.AckPurgedPayloadsAsync(input, CancellationToken.None); | ||
| this.logger.BlobPurgeAckedPayloads(input.Count); | ||
| return null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Microsoft.DurableTask.AzureBlobPayloads; | ||
|
|
||
| /// <summary> | ||
| /// Activity that deletes a single externalized payload blob given its token. Deletion is idempotent, so | ||
| /// re-delivered tokens and concurrent workers are safe. Malformed (poison) tokens are discarded so they get | ||
| /// acknowledged instead of retried forever; transient failures leave the payload tombstoned to retry. | ||
| /// </summary> | ||
| /// <param name="store">The payload store used to delete blobs.</param> | ||
| /// <param name="logger">The logger instance.</param> | ||
| [DurableTask] | ||
| public class DeleteExternalBlobActivity( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we delete a batch instead of one at at time per activity call |
||
| PayloadStore store, | ||
| ILogger<DeleteExternalBlobActivity> logger) | ||
| : TaskActivity<string, BlobDeleteResult> | ||
| { | ||
| readonly PayloadStore store = Check.NotNull(store); | ||
| readonly ILogger<DeleteExternalBlobActivity> logger = Check.NotNull(logger); | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async Task<BlobDeleteResult> RunAsync(TaskActivityContext context, string input) | ||
| { | ||
| Check.NotNullOrEmpty(input, nameof(input)); | ||
|
|
||
| try | ||
| { | ||
| await this.store.DeleteAsync(input, CancellationToken.None); | ||
| return BlobDeleteResult.Deleted; | ||
| } | ||
| catch (ArgumentException ex) | ||
| { | ||
| // The token is malformed or points at a different container; it can never succeed. Discard it so | ||
| // the backend clears the row instead of re-streaming the same poison token every cycle. | ||
| this.logger.BlobPurgeDeleteDiscarded(ex, input); | ||
| return BlobDeleteResult.Discarded; | ||
| } | ||
| catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) | ||
| { | ||
| // Transient failure: leave the payload tombstoned so a later purge cycle can retry it. A single | ||
| // bad token must not fail the whole batch. | ||
| this.logger.BlobPurgeDeleteFailed(ex, input); | ||
| return BlobDeleteResult.Retry; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Microsoft.DurableTask.Client; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Microsoft.DurableTask.AzureBlobPayloads; | ||
|
|
||
| /// <summary> | ||
| /// Activity that fetches a batch of tombstoned payloads from the backend for the auto-purge job to delete. | ||
| /// </summary> | ||
| /// <param name="client">The Durable Task client used to query the backend for tombstoned payloads.</param> | ||
| /// <param name="logger">The logger instance.</param> | ||
| [DurableTask] | ||
| public class GetTombstonedPayloadsActivity( | ||
| DurableTaskClient client, | ||
| ILogger<GetTombstonedPayloadsActivity> logger) | ||
| : TaskActivity<int, List<TombstonedPayload>> | ||
| { | ||
| readonly DurableTaskClient client = Check.NotNull(client); | ||
| readonly ILogger<GetTombstonedPayloadsActivity> logger = Check.NotNull(logger); | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async Task<List<TombstonedPayload>> RunAsync(TaskActivityContext context, int input) | ||
| { | ||
| int limit = input > 0 ? input : BlobPurgeConstants.DefaultBatchSize; | ||
| List<TombstonedPayload> payloads = | ||
| await this.client.GetTombstonedPayloadsAsync(limit, CancellationToken.None); | ||
| this.logger.BlobPurgeFetchedTombstones(payloads.Count); | ||
| return payloads; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Microsoft.DurableTask.Client; | ||
| using Microsoft.DurableTask.Entities; | ||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| namespace Microsoft.DurableTask.AzureBlobPayloads; | ||
|
|
||
| /// <summary> | ||
| /// Client-side hosted service that ensures the singleton blob payload auto-purge job exists when auto-purge | ||
| /// is enabled via <see cref="LargePayloadStorageOptions.AutoPurge"/>. It never blocks host startup: it runs | ||
| /// on a background task and retries until the backend is reachable. The job is a whole-scheduler singleton, | ||
| /// so racing client processes simply no-op. | ||
| /// </summary> | ||
| sealed class BlobPurgeJobStarter : IHostedService | ||
| { | ||
| static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(10); | ||
|
|
||
| readonly DurableTaskClient client; | ||
| readonly IOptionsMonitor<LargePayloadStorageOptions> options; | ||
| readonly string builderName; | ||
| readonly ILogger<BlobPurgeJobStarter> logger; | ||
| readonly EntityInstanceId entityId = new(nameof(BlobPurgeJob), BlobPurgeConstants.JobId); | ||
|
|
||
| CancellationTokenSource? cts; | ||
| Task? ensureTask; | ||
|
|
||
| public BlobPurgeJobStarter( | ||
| DurableTaskClient client, | ||
| IOptionsMonitor<LargePayloadStorageOptions> options, | ||
| string builderName, | ||
| ILogger<BlobPurgeJobStarter> logger) | ||
| { | ||
| this.client = Check.NotNull(client); | ||
| this.options = Check.NotNull(options); | ||
| this.builderName = Check.NotNull(builderName); | ||
| this.logger = Check.NotNull(logger); | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public Task StartAsync(CancellationToken cancellationToken) | ||
| { | ||
| LargePayloadStorageOptions opts = this.options.Get(this.builderName); | ||
| if (!opts.AutoPurge) | ||
| { | ||
| this.logger.BlobPurgeDisabled(); | ||
| return Task.CompletedTask; | ||
|
Comment on lines
+46
to
+50
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this a defensive check? since this starter class should be conditionally di based on auto purge flag before checking here right? |
||
| } | ||
|
|
||
| int batchSize = opts.PayloadPurgeBatchSize > 0 ? opts.PayloadPurgeBatchSize : BlobPurgeConstants.DefaultBatchSize; | ||
|
|
||
| // Do not block host startup; ensure the job on a background task with basic retry until the backend | ||
| // is reachable. | ||
| this.cts = new CancellationTokenSource(); | ||
| this.ensureTask = Task.Run(() => this.EnsureJobAsync(batchSize, this.cts.Token), CancellationToken.None); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public async Task StopAsync(CancellationToken cancellationToken) | ||
| { | ||
| this.cts?.Cancel(); | ||
|
|
||
| Task? pending = this.ensureTask; | ||
| if (pending is not null) | ||
| { | ||
| // The ensure loop observes cancellation and returns promptly; swallow any faulted/cancelled result. | ||
| await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| async Task EnsureJobAsync(int batchSize, CancellationToken cancellationToken) | ||
| { | ||
| while (!cancellationToken.IsCancellationRequested) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why this is looping forever, what i want is start the job once if not exist, and there should only be one job running at any time, and if the job failed only you should retry scheduling it |
||
| { | ||
| try | ||
| { | ||
| // The singleton is already guaranteed by the entity's fixed key (Create no-ops when the job is | ||
| // active) and the orchestrator's fixed instance id, so just schedule the bridge once with a | ||
| // fixed instance id. Retry only if the backend is unreachable at startup. | ||
| BlobPurgeJobOperationRequest request = new( | ||
| this.entityId, nameof(BlobPurgeJob.Create), batchSize); | ||
|
|
||
| await this.client.ScheduleNewOrchestrationInstanceAsync( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just scheduled is enough? |
||
| new TaskName(nameof(ExecuteBlobPurgeJobOperationOrchestrator)), | ||
| request, | ||
| new StartOrchestrationOptions(BlobPurgeConstants.StarterInstanceId), | ||
| cancellationToken); | ||
|
|
||
| this.logger.BlobPurgeJobEnsured(); | ||
| return; | ||
| } | ||
| catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) | ||
| { | ||
| return; | ||
| } | ||
| catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did you check if its this job already exist case whether it throws exception, if not we do not want to retry |
||
| { | ||
| this.logger.BlobPurgeStarterRetry(ex); | ||
| try | ||
| { | ||
| await Task.Delay(RetryDelay, cancellationToken); | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lets add limit range check, not here whenever limit is specified, > 0 and < 1000