Skip to content

Race condition in GetItemAsyncCore causes empty forward dependencies under concurrent load #3

@gfraiteur

Description

@gfraiteur

Summary

A race condition in GetItemAsyncCore can cause it to return cache items with empty or incorrect dependencies when dependencies are enabled and concurrent writes occur.

Root Cause

GetItemAsyncCore performs multiple non-atomic reads:

  1. Read version
  2. Read value
  3. Read forward dependencies

During this multi-step read, a concurrent SetItem or RemoveItem can modify or delete the item, causing inconsistency between the value read in step 2 and the forward dependencies read in step 3.

Symptoms

Under high load with concurrent read/write operations:

  • GetItem returns items with empty Dependencies array when they should have dependencies
  • The serialized payload contains the expected dependencies, but the forward dependencies list was deleted/modified concurrently
  • Error: "Corrupted dependencies - different number of expected and actual dependencies"

Fix

Add consistency verification to GetItemAsyncCore when includeDependencies is true:

  1. After reading forward dependencies, verify the value still exists
  2. Verify the version hasn't changed since we started reading
  3. If version changed, retry the entire read using TransactionRetryPolicy
  4. If retry policy exhausted, return null
protected override async ValueTask<CacheItem?> GetItemAsyncCore( string key, bool includeDependencies, CancellationToken cancellationToken )
{
    var shortKey = GetShortKeyFromUserKey( key );
    var versionKey = this.KeyBuilder.GetVersionKey( shortKey );

    object? retryState = null;

    for ( var attempt = 0;
          await this.Configuration.TransactionRetryPolicy.TryAsync( OperationKind.GetItem, attempt, null, ref retryState, cancellationToken );
          attempt++ )
    {
        var version = await this.GetVersionOrNullAsync( shortKey );
        if ( version == null ) return null;

        var keyAndVersion = new ShortKeyAndVersion( shortKey, version );
        var valueKey = this.KeyBuilder.GetVersionedValueKey( keyAndVersion );
        var serializedValue = await this.Database.StringGetAsync( valueKey, ... );
        if ( !serializedValue.HasValue ) return null;

        ImmutableArray<string> dependencies = default;

        if ( includeDependencies )
        {
            var forwardDeps = await this.GetForwardDependenciesAsync( keyAndVersion );

            // Verify consistency after multi-step read
            if ( !await this.Database.KeyExistsAsync( valueKey, ... ) )
            {
                return null; // Value deleted
            }

            var currentVersion = await this.GetVersionOrNullAsync( shortKey );
            if ( currentVersion != version )
            {
                continue; // Version changed - retry
            }

            // ... convert forwardDeps to dependencies
        }

        return this.ItemSerializer.Deserialize( serializedValue, dependencies ).Item;
    }

    return null; // Retry policy exhausted
}

Affected Code

  • Metalama.Patterns.Caching.Backends.Redis/DependenciesRedisCachingBackend.cs - GetItemAsyncCore method

Filed by Claude

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions