@@ -25,12 +25,10 @@ internal class DockerService : IDockerService
2525 private static readonly DockerClient Client = new DockerClientConfiguration ( ) . CreateClient ( ) ;
2626
2727 /// <summary>
28- /// Serializes image pull operations so only one pull runs at a time,
29- /// and tracks which images have already been pulled to avoid redundant work .
28+ /// Tracks in-flight and completed image pulls so each image is pulled at most once.
29+ /// Concurrent callers for the same image await the same task .
3030 /// </summary>
31- private static readonly SemaphoreSlim PullSemaphore = new ( 1 , 1 ) ;
32-
33- private static readonly ConcurrentDictionary < string , bool > PulledImages = new ( ) ;
31+ private static readonly ConcurrentDictionary < string , Task < bool > > PullCache = new ( ) ;
3432
3533 private static int incrementingContainerId ;
3634
@@ -105,41 +103,34 @@ private async Task<ImageInspectResponse> InspectImageAndSanitizeVarsAsync(string
105103
106104 public async Task < bool > TryPullImageAsync ( string image , CancellationToken cancellationToken = default )
107105 {
108- // Fast path: already pulled in this process
109- if ( PulledImages . ContainsKey ( image ) )
106+ // Check if already available locally before attempting a pull
107+ if ( await this . ImageExistsLocallyAsync ( image , cancellationToken ) )
110108 {
109+ PullCache . TryAdd ( image , Task . FromResult ( true ) ) ;
111110 return true ;
112111 }
113112
114- // Check if already available locally before acquiring the semaphore
115- if ( await this . ImageExistsLocallyAsync ( image , cancellationToken ) )
113+ var tcs = new TaskCompletionSource < bool > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
114+ var existingTask = PullCache . GetOrAdd ( image , tcs . Task ) ;
115+
116+ if ( existingTask != tcs . Task )
116117 {
117- PulledImages . TryAdd ( image , true ) ;
118- return true ;
118+ // Another caller is already pulling this image — await their result.
119+ return await existingTask ;
119120 }
120121
121- await PullSemaphore . WaitAsync ( cancellationToken ) ;
122+ // We own this cache entry — perform the actual pull.
122123 try
123124 {
124- // Double-check after acquiring semaphore — another caller may have
125- // pulled this image (or a different image that satisfied the local check)
126- // while we were waiting.
127- if ( PulledImages . ContainsKey ( image ) )
128- {
129- return true ;
130- }
131-
132125 var result = await this . PullImageCoreAsync ( image , cancellationToken ) ;
133- if ( result )
134- {
135- PulledImages . TryAdd ( image , true ) ;
136- }
137-
126+ tcs . SetResult ( result ) ;
138127 return result ;
139128 }
140- finally
129+ catch ( Exception ex )
141130 {
142- PullSemaphore . Release ( ) ;
131+ PullCache . TryRemove ( image , out _ ) ;
132+ tcs . SetException ( ex ) ;
133+ throw ;
143134 }
144135 }
145136
0 commit comments