< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: Microsoft.Extensions.DependencyInjection.DynamoDbDurableFlowServiceCollectionExtensions
Assembly: AsyncResponse.DurableFlows.DynamoDB
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.DynamoDB/DynamoDbDurableFlows.cs
Line coverage
100%
Covered lines: 9
Uncovered lines: 0
Coverable lines: 9
Total lines: 505
Line coverage: 100%
Branch coverage
75%
Covered branches: 3
Total branches: 4
Branch coverage: 75%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
WithDynamoDbDurableFlows(...)100%22100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/DurableFlows/AsyncResponse.DurableFlows.DynamoDB/DynamoDbDurableFlows.cs

#LineLine coverage
 1using Amazon.DynamoDBv2;
 2using Amazon.DynamoDBv2.Model;
 3using AsyncResponse;
 4using AsyncResponse.DurableFlows.DynamoDB;
 5using AsyncResponse.DurableFlows.Internal;
 6using Microsoft.Extensions.DependencyInjection.Extensions;
 7using Microsoft.Extensions.Options;
 8using System.Globalization;
 9
 10namespace Microsoft.Extensions.DependencyInjection
 11{
 12    /// <summary>DI registration for the DynamoDB durable-flow state store.</summary>
 13    public static class DynamoDbDurableFlowServiceCollectionExtensions
 14    {
 15        /// <summary>
 16        /// Stores durable-flow state in DynamoDB. Hosts may register an <see cref="IAmazonDynamoDB"/>
 17        /// client; otherwise the default AWS credential/region chain is used.
 18        /// </summary>
 19        public static AsyncResponseRegistrationBuilder WithDynamoDbDurableFlows(
 20            this AsyncResponseRegistrationBuilder builder,
 21            Action<DynamoDbDurableFlowOptions>? configure = null)
 22        {
 23            // Singleton on purpose: table/TTL provisioning is cached per store instance and DynamoDB
 24            // control-plane calls are throttled account-wide — a scoped store would re-issue them on
 25            // every flow execution. A host-registered IAmazonDynamoDB is reused when present;
 26            // otherwise the store creates and owns a client from the default AWS credential/region
 27            // chain. Nothing is registered as a bare IAmazonDynamoDB service, so unrelated
 28            // resolutions of that type are never answered — or broken — by this package.
 229            builder.Services.TryAddSingleton(provider =>
 230            {
 231                var options = provider.GetRequiredService<IOptions<DynamoDbDurableFlowOptions>>();
 232                var shared = provider.GetService<IAmazonDynamoDB>();
 233                return shared is not null
 234                    ? new DynamoDbFlowStateStore(shared, options)
 235                    : new DynamoDbFlowStateStore(new AmazonDynamoDBClient(), options, ownsClient: true);
 236            });
 237            return builder.WithDurableFlows<DynamoDbFlowStateStore, DynamoDbDurableFlowOptions>(configure);
 38        }
 39    }
 40}
 41
 42namespace AsyncResponse.DurableFlows.DynamoDB
 43{
 44/// <summary>Options for the DynamoDB durable-flow state store.</summary>
 45public sealed class DynamoDbDurableFlowOptions : DurableFlowOptions
 46{
 47    /// <summary>Table storing one durable-flow ledger item per flow id.</summary>
 48    public string TableName { get; set; } = "AsyncResponseFlowState";
 49
 50    /// <summary>Creates the table on first use when it does not exist.</summary>
 51    public bool AutoCreateTable { get; set; } = true;
 52
 53    /// <summary>Enables DynamoDB TTL on the expiry attribute when auto-creating the table.</summary>
 54    public bool EnableTimeToLive { get; set; } = true;
 55
 56    /// <summary>Attribute used for DynamoDB TTL. Default: <c>expires_at</c>.</summary>
 57    public string TimeToLiveAttributeName { get; set; } = "expires_at";
 58
 59    /// <summary>
 60    /// Maximum serialized flow-state size in bytes accepted by writes; oversized ledgers fail fast
 61    /// with an actionable error instead of the raw 400 KB item-cap ValidationException the
 62    /// executor would retry into the dead-letter queue. Default: 350 KB (headroom under DynamoDB's
 63    /// 400 KB item cap for the sibling attributes); <c>null</c> disables the guard.
 64    /// </summary>
 65    public long? MaxStateBytes { get; set; } = 350_000;
 66
 67    /// <summary>Validates option values and throws on misconfiguration.</summary>
 68    public void Validate()
 69    {
 70        if (string.IsNullOrWhiteSpace(TableName))
 71            throw new InvalidOperationException($"{nameof(DynamoDbDurableFlowOptions)}.{nameof(TableName)} must be confi
 72        if (string.IsNullOrWhiteSpace(TimeToLiveAttributeName))
 73            throw new InvalidOperationException($"{nameof(DynamoDbDurableFlowOptions)}.{nameof(TimeToLiveAttributeName)}
 74        if (MaxStateBytes is <= 0)
 75            throw new InvalidOperationException($"{nameof(DynamoDbDurableFlowOptions)}.{nameof(MaxStateBytes)} must be p
 76    }
 77}
 78
 79/// <summary>DynamoDB implementation of <see cref="IFlowStateStore"/>.</summary>
 80public sealed class DynamoDbFlowStateStore : IFlowStateStore, IDisposable
 81{
 82    private const string FlowIdAttribute = "flow_id";
 83    private const string StateJsonAttribute = "state_json";
 84    private const string UpdatedAtAttribute = "updated_at";
 85    private const string RevisionAttribute = "revision";
 86    private const string LeaseIdAttribute = "lease_id";
 87    private const string LeaseExpiresAtAttribute = "lease_expires_at_ms";
 88
 89    // Time authority: this store keeps the app clock (DateTimeOffset.UtcNow) for expiry and lease
 90    // comparisons. DynamoDB condition expressions evaluate client-supplied values only — there is
 91    // no server-clock function available in a conditional write — so multi-node deployments
 92    // should keep worker clocks synchronized well inside the lease window. (DynamoDB's own TTL
 93    // reaper, by contrast, runs on the service clock against the epoch-seconds expiry attribute.)
 94
 95    private readonly IAmazonDynamoDB _client;
 96    private readonly DynamoDbDurableFlowOptions _options;
 97    private readonly SemaphoreSlim _ensureGate = new(1, 1);
 98    private readonly bool _ownsClient;
 99    private bool _created;
 100
 101    public DynamoDbFlowStateStore(IAmazonDynamoDB client, IOptions<DynamoDbDurableFlowOptions> options, bool ownsClient 
 102    {
 103        _client = client;
 104        _options = options.Value;
 105        _options.Validate();
 106        _ownsClient = ownsClient;
 107    }
 108
 109    public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default)
 110    {
 111        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 112        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 113
 114        var response = await _client.GetItemAsync(new GetItemRequest
 115        {
 116            TableName = _options.TableName,
 117            Key = Key(flowId),
 118            ConsistentRead = true
 119        }, cancellationToken).ConfigureAwait(false);
 120
 121        if (response.Item is null || response.Item.Count == 0)
 122            return null;
 123        if (!response.Item.TryGetValue(_options.TimeToLiveAttributeName, out var expires) ||
 124            !long.TryParse(expires.N, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expiresAt) ||
 125            expiresAt <= DateTimeOffset.UtcNow.ToUnixTimeSeconds())
 126            return null;
 127        if (!response.Item.TryGetValue(StateJsonAttribute, out var json) || string.IsNullOrEmpty(json.S))
 128            return null;
 129
 130        if (!response.Item.TryGetValue(RevisionAttribute, out var revision)
 131            || !long.TryParse(revision.N, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
 132            return null;
 133
 134        return DurableFlowStoreShared.ReadState(flowId, json.S, value);
 135    }
 136
 137    public async Task<bool> TryCreateAsync(string flowId, FlowState state, TimeSpan ttl, CancellationToken cancellationT
 138    {
 139        DurableFlowStoreShared.ValidateCreate(flowId, state, ttl);
 140        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "DynamoDB");
 141        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 142
 143        var now = DateTimeOffset.UtcNow;
 144        try
 145        {
 146            await _client.PutItemAsync(new PutItemRequest
 147            {
 148                TableName = _options.TableName,
 149                Item = CreateItem(flowId, stateJson, state.Revision, ttl, now),
 150                ConditionExpression = "attribute_not_exists(#flow_id) OR #expires <= :now",
 151                ExpressionAttributeNames = new Dictionary<string, string>
 152                {
 153                    ["#flow_id"] = FlowIdAttribute,
 154                    ["#expires"] = _options.TimeToLiveAttributeName
 155                },
 156                ExpressionAttributeValues = new Dictionary<string, AttributeValue>
 157                {
 158                    [":now"] = new() { N = UnixSeconds(now) }
 159                }
 160            }, cancellationToken).ConfigureAwait(false);
 161            return true;
 162        }
 163        catch (ConditionalCheckFailedException)
 164        {
 165            return false;
 166        }
 167    }
 168
 169    public async Task<bool> TryUpdateAsync(
 170        string flowId,
 171        FlowState state,
 172        long expectedRevision,
 173        TimeSpan ttl,
 174        string? leaseId = null,
 175        CancellationToken cancellationToken = default)
 176    {
 177        DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl);
 178        var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "DynamoDB");
 179        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 180
 181        var now = DateTimeOffset.UtcNow;
 182        var names = new Dictionary<string, string>
 183        {
 184            ["#state"] = StateJsonAttribute,
 185            ["#expires"] = _options.TimeToLiveAttributeName,
 186            ["#updated"] = UpdatedAtAttribute,
 187            ["#revision"] = RevisionAttribute
 188        };
 189        var values = new Dictionary<string, AttributeValue>
 190        {
 191            [":state"] = new() { S = stateJson },
 192            [":expires"] = new() { N = UnixSecondsCeiling(DurableFlowStoreShared.AddSaturating(now, ttl)) },
 193            [":updated"] = new() { N = UnixSeconds(now) },
 194            [":expected_revision"] = new() { N = expectedRevision.ToString(CultureInfo.InvariantCulture) },
 195            [":new_revision"] = new() { N = state.Revision.ToString(CultureInfo.InvariantCulture) },
 196            [":now"] = new() { N = UnixSeconds(now) }
 197        };
 198        var condition = "#revision = :expected_revision AND #expires > :now";
 199        if (leaseId is not null)
 200        {
 201            condition += " AND #lease_id = :lease_id AND #lease_expires > :now_ms";
 202            names["#lease_id"] = LeaseIdAttribute;
 203            names["#lease_expires"] = LeaseExpiresAtAttribute;
 204            values[":lease_id"] = new AttributeValue { S = leaseId };
 205            values[":now_ms"] = new AttributeValue { N = UnixMilliseconds(now) };
 206        }
 207
 208        try
 209        {
 210            await _client.UpdateItemAsync(new UpdateItemRequest
 211            {
 212                TableName = _options.TableName,
 213                Key = Key(flowId),
 214                UpdateExpression = "SET #state = :state, #expires = :expires, #updated = :updated, #revision = :new_revi
 215                ConditionExpression = condition,
 216                ExpressionAttributeNames = names,
 217                ExpressionAttributeValues = values
 218            }, cancellationToken).ConfigureAwait(false);
 219            return true;
 220        }
 221        catch (ConditionalCheckFailedException)
 222        {
 223            return false;
 224        }
 225    }
 226
 227    public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc
 228        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: true, cancellationToken);
 229
 230    public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel
 231        => UpdateLeaseAsync(flowId, leaseId, leaseDuration, acquire: false, cancellationToken);
 232
 233    public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default)
 234    {
 235        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 236        try
 237        {
 238            await _client.UpdateItemAsync(new UpdateItemRequest
 239            {
 240                TableName = _options.TableName,
 241                Key = Key(flowId),
 242                UpdateExpression = "REMOVE #lease_id, #lease_expires",
 243                ConditionExpression = "#lease_id = :lease_id",
 244                ExpressionAttributeNames = new Dictionary<string, string>
 245                {
 246                    ["#lease_id"] = LeaseIdAttribute,
 247                    ["#lease_expires"] = LeaseExpiresAtAttribute
 248                },
 249                ExpressionAttributeValues = new Dictionary<string, AttributeValue>
 250                {
 251                    [":lease_id"] = new() { S = leaseId }
 252                }
 253            }, cancellationToken).ConfigureAwait(false);
 254        }
 255        catch (ConditionalCheckFailedException)
 256        {
 257        }
 258    }
 259
 260    public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default)
 261    {
 262        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 263        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 264
 265        var response = await _client.DeleteItemAsync(new DeleteItemRequest
 266        {
 267            TableName = _options.TableName,
 268            Key = Key(flowId),
 269            ReturnValues = ReturnValue.ALL_OLD
 270        }, cancellationToken).ConfigureAwait(false);
 271        return response.Attributes is { Count: > 0 };
 272    }
 273
 274    private async Task EnsureCreatedAsync(CancellationToken cancellationToken)
 275    {
 276        if (_created)
 277            return;
 278
 279        await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 280        try
 281        {
 282            if (_created)
 283                return;
 284
 285            TableDescription? table = null;
 286            try
 287            {
 288                var described = await _client.DescribeTableAsync(_options.TableName, cancellationToken).ConfigureAwait(f
 289                table = described.Table;
 290            }
 291            catch (ResourceNotFoundException)
 292            {
 293            }
 294
 295            if (table is null)
 296            {
 297                if (!_options.AutoCreateTable)
 298                    throw new InvalidOperationException(
 299                        $"DynamoDB table '{_options.TableName}' does not exist and {nameof(DynamoDbDurableFlowOptions.Au
 300
 301                try
 302                {
 303                    await _client.CreateTableAsync(new CreateTableRequest
 304                    {
 305                        TableName = _options.TableName,
 306                        BillingMode = BillingMode.PAY_PER_REQUEST,
 307                        AttributeDefinitions =
 308                        [
 309                            new AttributeDefinition(FlowIdAttribute, ScalarAttributeType.S)
 310                        ],
 311                        KeySchema =
 312                        [
 313                            new KeySchemaElement(FlowIdAttribute, KeyType.HASH)
 314                        ]
 315                    }, cancellationToken).ConfigureAwait(false);
 316                }
 317                catch (ResourceInUseException)
 318                {
 319                    // Another process won the create race; fall through and wait for ACTIVE.
 320                }
 321
 322                table = await WaitForTableActiveAsync(cancellationToken).ConfigureAwait(false);
 323            }
 324            else if (table.TableStatus != TableStatus.ACTIVE)
 325            {
 326                table = await WaitForTableActiveAsync(cancellationToken).ConfigureAwait(false);
 327            }
 328
 329            ValidateTableSchema(table);
 330
 331            if (_options.EnableTimeToLive)
 332            {
 333                // Check the TTL status instead of blind-enabling: UpdateTimeToLive throws when TTL
 334                // is already enabled, and relying on a swallowed exception per provisioning is noise.
 335                var ttlStatus = await _client.DescribeTimeToLiveAsync(new DescribeTimeToLiveRequest
 336                {
 337                    TableName = _options.TableName
 338                }, cancellationToken).ConfigureAwait(false);
 339
 340                var description = ttlStatus.TimeToLiveDescription;
 341                var status = description?.TimeToLiveStatus;
 342                if ((status == TimeToLiveStatus.ENABLED || status == TimeToLiveStatus.ENABLING)
 343                    && !string.Equals(description?.AttributeName, _options.TimeToLiveAttributeName, StringComparison.Ord
 344                {
 345                    throw new InvalidOperationException(
 346                        $"DynamoDB table '{_options.TableName}' has TTL configured on attribute '{description?.Attribute
 347                        $"but '{_options.TimeToLiveAttributeName}' is required.");
 348                }
 349
 350                if (status != TimeToLiveStatus.ENABLED && status != TimeToLiveStatus.ENABLING)
 351                {
 352                    if (!_options.AutoCreateTable)
 353                    {
 354                        throw new InvalidOperationException(
 355                            $"DynamoDB table '{_options.TableName}' does not have TTL enabled on " +
 356                            $"'{_options.TimeToLiveAttributeName}'. Enable it in infrastructure before using the table."
 357                    }
 358
 359                    try
 360                    {
 361                        await _client.UpdateTimeToLiveAsync(new UpdateTimeToLiveRequest
 362                        {
 363                            TableName = _options.TableName,
 364                            TimeToLiveSpecification = new TimeToLiveSpecification
 365                            {
 366                                AttributeName = _options.TimeToLiveAttributeName,
 367                                Enabled = true
 368                            }
 369                        }, cancellationToken).ConfigureAwait(false);
 370                    }
 371                    catch (AmazonDynamoDBException ex) when (string.Equals(ex.ErrorCode, "ValidationException", StringCo
 372                    {
 373                        // Accept only the one safe race: another process enabled the expected TTL
 374                        // attribute between our describe and update. Do not swallow unrelated
 375                        // validation failures.
 376                        var raced = await _client.DescribeTimeToLiveAsync(new DescribeTimeToLiveRequest
 377                        {
 378                            TableName = _options.TableName
 379                        }, cancellationToken).ConfigureAwait(false);
 380                        var racedDescription = raced.TimeToLiveDescription;
 381                        if ((racedDescription?.TimeToLiveStatus != TimeToLiveStatus.ENABLED
 382                                && racedDescription?.TimeToLiveStatus != TimeToLiveStatus.ENABLING)
 383                            || !string.Equals(racedDescription.AttributeName, _options.TimeToLiveAttributeName, StringCo
 384                        {
 385                            throw;
 386                        }
 387                    }
 388                }
 389            }
 390
 391            _created = true;
 392        }
 393        finally
 394        {
 395            _ensureGate.Release();
 396        }
 397    }
 398
 399    private async Task<TableDescription> WaitForTableActiveAsync(CancellationToken cancellationToken)
 400    {
 401        var deadline = DateTime.UtcNow.AddSeconds(30);
 402        while (true)
 403        {
 404            var response = await _client.DescribeTableAsync(_options.TableName, cancellationToken).ConfigureAwait(false)
 405            if (response.Table.TableStatus == TableStatus.ACTIVE)
 406                return response.Table;
 407            if (DateTime.UtcNow >= deadline)
 408                throw new TimeoutException($"DynamoDB table '{_options.TableName}' did not become ACTIVE within 30 secon
 409
 410            await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
 411        }
 412    }
 413
 414    private void ValidateTableSchema(TableDescription table)
 415    {
 416        var hashKey = table.KeySchema?.SingleOrDefault(key => key.KeyType == KeyType.HASH);
 417        var keyDefinition = table.AttributeDefinitions?
 418            .SingleOrDefault(attribute => string.Equals(attribute.AttributeName, FlowIdAttribute, StringComparison.Ordin
 419        if (table.KeySchema?.Count != 1
 420            || !string.Equals(hashKey?.AttributeName, FlowIdAttribute, StringComparison.Ordinal)
 421            || keyDefinition?.AttributeType != ScalarAttributeType.S)
 422        {
 423            throw new InvalidOperationException(
 424                $"DynamoDB table '{_options.TableName}' must use one string partition key named '{FlowIdAttribute}' and 
 425        }
 426    }
 427
 428    private async Task<bool> UpdateLeaseAsync(
 429        string flowId,
 430        string leaseId,
 431        TimeSpan leaseDuration,
 432        bool acquire,
 433        CancellationToken cancellationToken)
 434    {
 435        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 436        ArgumentException.ThrowIfNullOrWhiteSpace(leaseId);
 437        if (leaseDuration <= TimeSpan.Zero)
 438            throw new ArgumentOutOfRangeException(nameof(leaseDuration));
 439
 440        await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
 441        var now = DateTimeOffset.UtcNow;
 442        try
 443        {
 444            await _client.UpdateItemAsync(new UpdateItemRequest
 445            {
 446                TableName = _options.TableName,
 447                Key = Key(flowId),
 448                UpdateExpression = "SET #lease_id = :lease_id, #lease_expires = :lease_expires",
 449                ConditionExpression = acquire
 450                    ? "#expires > :now AND attribute_exists(#revision) AND (attribute_not_exists(#lease_id) OR #lease_ex
 451                    : "#expires > :now AND attribute_exists(#revision) AND #lease_id = :lease_id AND #lease_expires > :n
 452                ExpressionAttributeNames = new Dictionary<string, string>
 453                {
 454                    ["#expires"] = _options.TimeToLiveAttributeName,
 455                    ["#revision"] = RevisionAttribute,
 456                    ["#lease_id"] = LeaseIdAttribute,
 457                    ["#lease_expires"] = LeaseExpiresAtAttribute
 458                },
 459                ExpressionAttributeValues = new Dictionary<string, AttributeValue>
 460                {
 461                    [":now"] = new() { N = UnixSeconds(now) },
 462                    [":now_ms"] = new() { N = UnixMilliseconds(now) },
 463                    [":lease_id"] = new() { S = leaseId },
 464                    [":lease_expires"] = new() { N = UnixMilliseconds(DurableFlowStoreShared.AddSaturating(now, leaseDur
 465                }
 466            }, cancellationToken).ConfigureAwait(false);
 467            return true;
 468        }
 469        catch (ConditionalCheckFailedException)
 470        {
 471            return false;
 472        }
 473    }
 474
 475    private Dictionary<string, AttributeValue> CreateItem(string flowId, string stateJson, long revision, TimeSpan ttl, 
 476        => new(StringComparer.Ordinal)
 477        {
 478            [FlowIdAttribute] = new() { S = flowId },
 479            [StateJsonAttribute] = new() { S = stateJson },
 480            [_options.TimeToLiveAttributeName] = new() { N = UnixSecondsCeiling(DurableFlowStoreShared.AddSaturating(now
 481            [UpdatedAtAttribute] = new() { N = UnixSeconds(now) },
 482            [RevisionAttribute] = new() { N = revision.ToString(CultureInfo.InvariantCulture) }
 483        };
 484
 485    private static Dictionary<string, AttributeValue> Key(string flowId)
 486        => new(StringComparer.Ordinal) { [FlowIdAttribute] = new AttributeValue { S = flowId } };
 487
 488    private static string UnixSeconds(DateTimeOffset value)
 489        => value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
 490
 491    private static string UnixSecondsCeiling(DateTimeOffset value)
 492        => ((long)Math.Ceiling(value.ToUnixTimeMilliseconds() / 1000.0)).ToString(CultureInfo.InvariantCulture);
 493
 494    private static string UnixMilliseconds(DateTimeOffset value)
 495        => value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
 496
 497    /// <summary>Disposes the DynamoDB client when the store created (and therefore owns) it.</summary>
 498    public void Dispose()
 499    {
 500        _ensureGate.Dispose();
 501        if (_ownsClient)
 502            _client.Dispose();
 503    }
 504}
 505}