Skip to content

Optimize performance of DeleteExpiresAsync#301

Merged
witskeeper merged 3 commits into
mainfrom
copilot/optimize-deleteexpiresasync-performance
Dec 2, 2025
Merged

Optimize performance of DeleteExpiresAsync#301
witskeeper merged 3 commits into
mainfrom
copilot/optimize-deleteexpiresasync-performance

Conversation

Copilot AI commented Nov 28, 2025

Copy link
Copy Markdown
Contributor

Thanks for assigning this issue to me. I'm starting to work on it and will keep this PR's description up to date as I form a plan and make progress.

Original prompt

This section details on the original issue you should resolve

<issue_title>优化DeleteExpiresAsync性能</issue_title>
<issue_description>

使用嵌套子查询的方式以提升性能,避免死锁

可以参照下面原版MySql的写法:

public async Task DeleteExpiresAsync(string table, DateTime timeout, int batchCount = 1000,
CancellationToken token = default)
{
var connection = new MySqlConnection(_options.Value.ConnectionString);
await using var _ = connection.ConfigureAwait(false);

    return await connection.ExecuteNonQueryAsync(
        $@"DELETE P FROM `{table}` AS P
           JOIN (
               SELECT Id
               FROM `{table}`
               WHERE ExpiresAt < @timeout
               AND StatusName IN ('{StatusName.Succeeded}', '{StatusName.Failed}')
               ORDER BY Id
               LIMIT @batchCount
               { (SupportSkipLocked ? "FOR UPDATE SKIP LOCKED" : "FOR UPDATE")}
           ) AS T ON P.Id = T.Id;",
        null,
        new MySqlParameter("@timeout", timeout),
        new MySqlParameter("@batchCount", batchCount)).ConfigureAwait(false);
}</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@witskeeper

Copy link
Copy Markdown
Member

@copilot 继续工作

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the DeleteExpiresAsync method to improve performance and reduce deadlock risk by adopting a two-step delete pattern: first selecting IDs of expired messages, then deleting by those IDs. This approach mirrors the pattern from the MySQL reference implementation but uses EF Core LINQ instead of raw SQL.

Key changes:

  • Changed from single-query direct delete to two-step pattern (SELECT IDs, then DELETE by IDs)
  • Added explicit null check for ExpiresAt field instead of using null-forgiving operator
  • Added early return optimization when no expired records are found

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +300 to 333
var ids = await context.PublishedMessages.AsNoTracking()
.Where(m => m.ExpiresAt != null && m.ExpiresAt < timeout && statusNames.Contains(m.StatusName))
.OrderBy(m => m.Id)
.Select(m => m.Id)
.Take(batchCount)
.ToListAsync(token);

if (ids.Count == 0)
{
return 0;
}

return await context.PublishedMessages
.Where(m => ids.Contains(m.Id))
.ExecuteDeleteAsync(token);
}
else if (table == NetCorePalStorageOptions.ReceivedMessageTableName)
{
return await context.ReceivedMessages
.Where(m => m.ExpiresAt! < timeout && statusNames.Contains(m.StatusName))
var ids = await context.ReceivedMessages.AsNoTracking()
.Where(m => m.ExpiresAt != null && m.ExpiresAt < timeout && statusNames.Contains(m.StatusName))
.OrderBy(m => m.Id)
.Select(m => m.Id)
.Take(batchCount)
.ToListAsync(token);

if (ids.Count == 0)
{
return 0;
}

return await context.ReceivedMessages
.Where(m => ids.Contains(m.Id))
.ExecuteDeleteAsync(token);
}

Copilot AI Dec 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for PublishedMessages (lines 300-314) and ReceivedMessages (lines 318-332) is duplicated. This violates the DRY (Don't Repeat Yourself) principle and makes the code harder to maintain.

Consider extracting the common logic into a helper method:

private async Task<int> DeleteExpiredMessagesAsync<TMessage>(
    DbSet<TMessage> dbSet, 
    DateTime timeout, 
    List<string> statusNames, 
    int batchCount, 
    CancellationToken token) 
    where TMessage : class
{
    var ids = await dbSet.AsNoTracking()
        .Where(m => EF.Property<DateTime?>(m, "ExpiresAt") != null && 
                    EF.Property<DateTime?>(m, "ExpiresAt") < timeout && 
                    statusNames.Contains(EF.Property<string>(m, "StatusName")))
        .OrderBy(m => EF.Property<long>(m, "Id"))
        .Select(m => EF.Property<long>(m, "Id"))
        .Take(batchCount)
        .ToListAsync(token);

    if (ids.Count == 0)
    {
        return 0;
    }

    return await dbSet
        .Where(m => ids.Contains(EF.Property<long>(m, "Id")))
        .ExecuteDeleteAsync(token);
}

Then simplify the if-else blocks to call this helper method.

Copilot uses AI. Check for mistakes.
Comment on lines +300 to 332
var ids = await context.PublishedMessages.AsNoTracking()
.Where(m => m.ExpiresAt != null && m.ExpiresAt < timeout && statusNames.Contains(m.StatusName))
.OrderBy(m => m.Id)
.Select(m => m.Id)
.Take(batchCount)
.ToListAsync(token);

if (ids.Count == 0)
{
return 0;
}

return await context.PublishedMessages
.Where(m => ids.Contains(m.Id))
.ExecuteDeleteAsync(token);
}
else if (table == NetCorePalStorageOptions.ReceivedMessageTableName)
{
return await context.ReceivedMessages
.Where(m => m.ExpiresAt! < timeout && statusNames.Contains(m.StatusName))
var ids = await context.ReceivedMessages.AsNoTracking()
.Where(m => m.ExpiresAt != null && m.ExpiresAt < timeout && statusNames.Contains(m.StatusName))
.OrderBy(m => m.Id)
.Select(m => m.Id)
.Take(batchCount)
.ToListAsync(token);

if (ids.Count == 0)
{
return 0;
}

return await context.ReceivedMessages
.Where(m => ids.Contains(m.Id))
.ExecuteDeleteAsync(token);

Copilot AI Dec 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reference MySQL implementation in the issue uses FOR UPDATE SKIP LOCKED to lock rows during selection, preventing race conditions and deadlocks. The current EF Core implementation lacks this locking mechanism, which could lead to:

  1. Race conditions: Rows could be modified/deleted between the SELECT and DELETE operations
  2. Potential deadlocks: Multiple cleanup processes could conflict when deleting the same records

While EF Core doesn't support FOR UPDATE SKIP LOCKED directly in LINQ, consider:

  • Using a transaction with appropriate isolation level
  • Using raw SQL with database-specific locking clauses for critical deployments
  • Documenting this limitation if the behavior is acceptable for the CAP cleanup use case

The two-step approach does improve performance by using a subquery pattern, but the lack of row-level locking differs from the reference implementation's approach to preventing deadlocks.

Copilot uses AI. Check for mistakes.
@witskeeper
witskeeper marked this pull request as ready for review December 2, 2025 02:43
@witskeeper witskeeper changed the title [WIP] Optimize performance of DeleteExpiresAsync Optimize performance of DeleteExpiresAsync Dec 2, 2025
@witskeeper
witskeeper merged commit cd28d98 into main Dec 2, 2025
1 check passed
@witskeeper
witskeeper deleted the copilot/optimize-deleteexpiresasync-performance branch December 2, 2025 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

优化DeleteExpiresAsync性能

3 participants