From c56a1a355ff01e3d717fded391aee84c0075e162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Thu, 1 Mar 2018 03:26:28 +0100 Subject: [PATCH] Move PruneAsync() to the authorization/token stores to allow creating more efficient implementations --- .../OpenIddictAuthorizationManager.cs | 64 +------------ .../Managers/OpenIddictTokenManager.cs | 64 +------------ .../Stores/IOpenIddictAuthorizationStore.cs | 10 +- .../Stores/IOpenIddictTokenStore.cs | 10 +- .../Stores/OpenIddictAuthorizationStore.cs | 35 +------ .../Stores/OpenIddictTokenStore.cs | 35 +------ .../Stores/OpenIddictAuthorizationStore.cs | 83 ++++++++++++++++ .../Stores/OpenIddictTokenStore.cs | 82 ++++++++++++++++ .../Stores/OpenIddictAuthorizationStore.cs | 95 +++++++++++++++++++ .../Stores/OpenIddictTokenStore.cs | 94 ++++++++++++++++++ 10 files changed, 371 insertions(+), 201 deletions(-) diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index b12b6916..c362a269 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -699,23 +699,6 @@ namespace OpenIddict.Core return Store.ListAsync(query, state, cancellationToken); } - /// - /// Lists the ad-hoc authorizations that are marked as invalid or have no - /// valid token attached and that can be safely removed from the database. - /// - /// The number of results to return. - /// The number of results to skip. - /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. - /// - public virtual Task> ListInvalidAsync( - [CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken = default) - { - return Store.ListInvalidAsync(count, offset, cancellationToken); - } - /// /// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached. /// @@ -723,51 +706,8 @@ namespace OpenIddict.Core /// /// A that can be used to monitor the asynchronous operation. /// - public virtual async Task PruneInvalidAsync(CancellationToken cancellationToken = default) - { - IList exceptions = null; - var authorizations = new List(); - - // First, start retrieving the invalid authorizations from the database. - for (var offset = 0; offset < 10_000; offset = offset + 100) - { - cancellationToken.ThrowIfCancellationRequested(); - - var results = await ListInvalidAsync(100, offset, cancellationToken); - if (results.IsEmpty) - { - break; - } - - authorizations.AddRange(results); - } - - // Then, remove the invalid authorizations one by one. - foreach (var authorization in authorizations) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - await DeleteAsync(authorization, cancellationToken); - } - - catch (Exception exception) - { - if (exceptions == null) - { - exceptions = new List(capacity: 1); - } - - exceptions.Add(exception); - } - } - - if (exceptions != null) - { - throw new AggregateException("An error occurred while pruning authorizations.", exceptions); - } - } + public virtual Task PruneAsync(CancellationToken cancellationToken = default) + => Store.PruneAsync(cancellationToken); /// /// Revokes an authorization. diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index f06c48d6..6dc9cad0 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -589,23 +589,6 @@ namespace OpenIddict.Core return Store.ListAsync(query, state, cancellationToken); } - /// - /// Lists the tokens that are marked as expired or invalid - /// and that can be safely removed from the database. - /// - /// The number of results to return. - /// The number of results to skip. - /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. - /// - public virtual Task> ListInvalidAsync( - [CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken = default) - { - return Store.ListInvalidAsync(count, offset, cancellationToken); - } - /// /// Obfuscates the specified reference identifier so it can be safely stored in a database. /// By default, this method returns a simple hashed representation computed using SHA256. @@ -637,51 +620,8 @@ namespace OpenIddict.Core /// /// A that can be used to monitor the asynchronous operation. /// - public virtual async Task PruneInvalidAsync(CancellationToken cancellationToken = default) - { - IList exceptions = null; - var tokens = new List(); - - // First, start retrieving the invalid tokens from the database. - for (var offset = 0; offset < 10_000; offset = offset + 100) - { - cancellationToken.ThrowIfCancellationRequested(); - - var results = await ListInvalidAsync(100, offset, cancellationToken); - if (results.IsEmpty) - { - break; - } - - tokens.AddRange(results); - } - - // Then, remove the invalid tokens one by one. - foreach (var token in tokens) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - await DeleteAsync(token, cancellationToken); - } - - catch (Exception exception) - { - if (exceptions == null) - { - exceptions = new List(capacity: 1); - } - - exceptions.Add(exception); - } - } - - if (exceptions != null) - { - throw new AggregateException("An error occurred while pruning tokens.", exceptions); - } - } + public virtual Task PruneAsync(CancellationToken cancellationToken = default) + => Store.PruneAsync(cancellationToken); /// /// Redeems a token. diff --git a/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs b/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs index 7c15d92c..38073448 100644 --- a/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs @@ -261,17 +261,13 @@ namespace OpenIddict.Core [CanBeNull] TState state, CancellationToken cancellationToken); /// - /// Lists the ad-hoc authorizations that are marked as invalid or have no - /// valid token attached and that can be safely removed from the database. + /// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached. /// - /// The number of results to return. - /// The number of results to skip. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. + /// A that can be used to monitor the asynchronous operation. /// - Task> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken); + Task PruneAsync(CancellationToken cancellationToken); /// /// Sets the application identifier associated with an authorization. diff --git a/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs b/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs index 11ca3528..57daeb73 100644 --- a/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs +++ b/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs @@ -294,17 +294,13 @@ namespace OpenIddict.Core [CanBeNull] TState state, CancellationToken cancellationToken); /// - /// Lists the tokens that are marked as expired or invalid - /// and that can be safely removed from the database. + /// Removes the tokens that are marked as expired or invalid. /// - /// The number of results to return. - /// The number of results to skip. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. + /// A that can be used to monitor the asynchronous operation. /// - Task> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken); + Task PruneAsync(CancellationToken cancellationToken); /// /// Sets the application identifier associated with a token. diff --git a/src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs index 2682edf6..0a846ce0 100644 --- a/src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs @@ -497,42 +497,13 @@ namespace OpenIddict.Core [CanBeNull] TState state, CancellationToken cancellationToken); /// - /// Lists the ad-hoc authorizations that are marked as invalid or have no - /// valid token attached and that can be safely removed from the database. + /// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached. /// - /// The number of results to return. - /// The number of results to skip. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. + /// A that can be used to monitor the asynchronous operation. /// - public virtual Task> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken) - { - IQueryable Query(IQueryable authorizations, int? skip, int? take) - { - var query = (from authorization in authorizations - where authorization.Status != OpenIddictConstants.Statuses.Valid || - (authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc && - !authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid)) - orderby authorization.Id - select authorization).AsQueryable(); - - if (skip.HasValue) - { - query = query.Skip(skip.Value); - } - - if (take.HasValue) - { - query = query.Take(take.Value); - } - - return query; - } - - return ListAsync((authorizations, state) => Query(authorizations, state.offset, state.count), (offset, count), cancellationToken); - } + public abstract Task PruneAsync(CancellationToken cancellationToken); /// /// Sets the application identifier associated with an authorization. diff --git a/src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs index 679e89e1..aea470fc 100644 --- a/src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; using System.Linq; @@ -526,41 +527,13 @@ namespace OpenIddict.Core [CanBeNull] TState state, CancellationToken cancellationToken); /// - /// Lists the tokens that are marked as expired or invalid - /// and that can be safely removed from the database. + /// Removes the tokens that are marked as expired or invalid. /// - /// The number of results to return. - /// The number of results to skip. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns all the elements returned when executing the specified query. + /// A that can be used to monitor the asynchronous operation. /// - public virtual Task> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken) - { - IQueryable Query(IQueryable tokens, int? skip, int? take) - { - var query = (from token in tokens - where token.ExpirationDate < DateTimeOffset.UtcNow || - token.Status != OpenIddictConstants.Statuses.Valid - orderby token.Id - select token).AsQueryable(); - - if (skip.HasValue) - { - query = query.Skip(skip.Value); - } - - if (take.HasValue) - { - query = query.Take(take.Value); - } - - return query; - } - - return ListAsync((tokens, state) => Query(tokens, state.offset, state.count), (offset, count), cancellationToken); - } + public abstract Task PruneAsync(CancellationToken cancellationToken); /// /// Sets the authorization identifier associated with a token. diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs index df50b5c5..dc436919 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Data.Entity; using System.Linq; using System.Threading; @@ -291,6 +292,88 @@ namespace OpenIddict.EntityFramework Authorizations.Include(authorization => authorization.Application), state).ToListAsync(cancellationToken)); } + /// + /// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public override async Task PruneAsync(CancellationToken cancellationToken = default) + { + // Note: Entity Framework 6.x doesn't support set-based deletes, which prevents removing + // entities in a single command without having to retrieve and materialize them first. + // To work around this limitation, entities are manually listed and deleted using a batch logic. + + IList exceptions = null; + + IQueryable Query(IQueryable authorizations, int offset) + => (from authorization in authorizations.Include(authorization => authorization.Tokens) + where authorization.Status != OpenIddictConstants.Statuses.Valid || + (authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc && + !authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid)) + orderby authorization.Id + select authorization).Skip(offset).Take(1_000); + + DbContextTransaction CreateTransaction() + { + // Note: relational providers like Sqlite are known to lack proper support + // for repeatable read transactions. To ensure this method can be safely used + // with such providers, the database transaction is created in a try/catch block. + try + { + return Context.Database.BeginTransaction(IsolationLevel.RepeatableRead); + } + + catch + { + return null; + } + } + + for (var offset = 0; offset < 100_000; offset = offset + 1_000) + { + cancellationToken.ThrowIfCancellationRequested(); + + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using (var transaction = CreateTransaction()) + { + var authorizations = await ListAsync((source, state) => Query(source, state), offset, cancellationToken); + if (authorizations.IsEmpty) + { + break; + } + + Authorizations.RemoveRange(authorizations); + Tokens.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) + { + if (exceptions == null) + { + exceptions = new List(capacity: 1); + } + + exceptions.Add(exception); + } + } + } + + if (exceptions != null) + { + throw new AggregateException("An error occurred while pruning authorizations.", exceptions); + } + } + /// /// Sets the application identifier associated with an authorization. /// diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs index 14873f05..bc34ecc9 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs @@ -5,7 +5,9 @@ */ using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Data.Entity; using System.Linq; using System.Threading; @@ -316,6 +318,86 @@ namespace OpenIddict.EntityFramework .Include(token => token.Authorization), state).ToListAsync(cancellationToken)); } + /// + /// Removes the tokens that are marked as expired or invalid. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public override async Task PruneAsync(CancellationToken cancellationToken = default) + { + // Note: Entity Framework 6.x doesn't support set-based deletes, which prevents removing + // entities in a single command without having to retrieve and materialize them first. + // To work around this limitation, entities are manually listed and deleted using a batch logic. + + IList exceptions = null; + + IQueryable Query(IQueryable tokens, int offset) + => (from token in tokens + where token.ExpirationDate < DateTimeOffset.UtcNow || + token.Status != OpenIddictConstants.Statuses.Valid + orderby token.Id + select token).Skip(offset).Take(1_000); + + DbContextTransaction CreateTransaction() + { + // Note: relational providers like Sqlite are known to lack proper support + // for repeatable read transactions. To ensure this method can be safely used + // with such providers, the database transaction is created in a try/catch block. + try + { + return Context.Database.BeginTransaction(IsolationLevel.RepeatableRead); + } + + catch + { + return null; + } + } + + for (var offset = 0; offset < 100_000; offset = offset + 1_000) + { + cancellationToken.ThrowIfCancellationRequested(); + + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using (var transaction = CreateTransaction()) + { + var tokens = await ListAsync((source, state) => Query(source, state), offset, cancellationToken); + if (tokens.IsEmpty) + { + break; + } + + Tokens.RemoveRange(tokens); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) + { + if (exceptions == null) + { + exceptions = new List(capacity: 1); + } + + exceptions.Add(exception); + } + } + } + + if (exceptions != null) + { + throw new AggregateException("An error occurred while pruning tokens.", exceptions); + } + } + /// /// Sets the application identifier associated with a token. /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs index 8191ebfe..1f941f5a 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs @@ -7,11 +7,14 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Caching.Memory; using OpenIddict.Core; using OpenIddict.Models; @@ -441,6 +444,98 @@ namespace OpenIddict.EntityFrameworkCore Authorizations.Include(authorization => authorization.Application), state).ToListAsync(cancellationToken)); } + /// + /// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public override async Task PruneAsync(CancellationToken cancellationToken = default) + { + // Note: Entity Framework Core doesn't support set-based deletes, which prevents removing + // entities in a single command without having to retrieve and materialize them first. + // To work around this limitation, entities are manually listed and deleted using a batch logic. + + IList exceptions = null; + + IQueryable Query(IQueryable authorizations, int offset) + => (from authorization in authorizations.Include(authorization => authorization.Tokens) + where authorization.Status != OpenIddictConstants.Statuses.Valid || + (authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc && + !authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid)) + orderby authorization.Id + select authorization).Skip(offset).Take(1_000); + + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + // Note: relational providers like Sqlite are known to lack proper support + // for repeatable read transactions. To ensure this method can be safely used + // with such providers, the database transaction is created in a try/catch block. + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + + for (var offset = 0; offset < 100_000; offset = offset + 1_000) + { + cancellationToken.ThrowIfCancellationRequested(); + + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using (var transaction = await CreateTransactionAsync()) + { + var authorizations = await ListAsync((source, state) => Query(source, state), offset, cancellationToken); + if (authorizations.IsEmpty) + { + break; + } + + Context.RemoveRange(authorizations); + Context.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) + { + if (exceptions == null) + { + exceptions = new List(capacity: 1); + } + + exceptions.Add(exception); + } + } + } + + if (exceptions != null) + { + throw new AggregateException("An error occurred while pruning authorizations.", exceptions); + } + } + /// /// Sets the application identifier associated with an authorization. /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs index 6a272c5c..2b646a7a 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs @@ -5,12 +5,16 @@ */ using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Caching.Memory; using OpenIddict.Core; using OpenIddict.Models; @@ -378,6 +382,96 @@ namespace OpenIddict.EntityFrameworkCore .Include(token => token.Authorization), state).ToListAsync(cancellationToken)); } + /// + /// Removes the tokens that are marked as expired or invalid. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public override async Task PruneAsync(CancellationToken cancellationToken = default) + { + // Note: Entity Framework Core doesn't support set-based deletes, which prevents removing + // entities in a single command without having to retrieve and materialize them first. + // To work around this limitation, entities are manually listed and deleted using a batch logic. + + IList exceptions = null; + + IQueryable Query(IQueryable tokens, int offset) + => (from token in tokens + where token.ExpirationDate < DateTimeOffset.UtcNow || + token.Status != OpenIddictConstants.Statuses.Valid + orderby token.Id + select token).Skip(offset).Take(1_000); + + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + // Note: relational providers like Sqlite are known to lack proper support + // for repeatable read transactions. To ensure this method can be safely used + // with such providers, the database transaction is created in a try/catch block. + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + + for (var offset = 0; offset < 100_000; offset = offset + 1_000) + { + cancellationToken.ThrowIfCancellationRequested(); + + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using (var transaction = await CreateTransactionAsync()) + { + var tokens = await ListAsync((source, state) => Query(source, state), offset, cancellationToken); + if (tokens.IsEmpty) + { + break; + } + + Context.RemoveRange(tokens); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) + { + if (exceptions == null) + { + exceptions = new List(capacity: 1); + } + + exceptions.Add(exception); + } + } + } + + if (exceptions != null) + { + throw new AggregateException("An error occurred while pruning tokens.", exceptions); + } + } + /// /// Sets the application identifier associated with a token. ///