2424logger = get_logger ("docker.context" )
2525
2626
27+ def _new_api_client (client : docker .DockerClient ) -> docker .APIClient :
28+ """
29+ Create a fresh low-level APIClient for each build to avoid connection
30+ contention across threads and to align API versions with the daemon.
31+ """
32+ try :
33+ base_url = client .api .base_url # e.g., 'unix://var/run/docker.sock'
34+ except Exception :
35+ base_url = None
36+
37+ try :
38+ api_version = client .version ().get ("ApiVersion" , "auto" )
39+ except Exception :
40+ api_version = "auto"
41+
42+ try :
43+ return docker .APIClient (base_url = base_url , version = api_version )
44+ except Exception :
45+ return docker .APIClient (version = "auto" )
46+
47+
2748def build_base_image (client : docker .DockerClient , ctx : DockerContext ) -> str :
2849 base_key = hash (ctx )
2950 base_tag = f"asv-base-rev-{ base_key } :base"
@@ -135,6 +156,9 @@ class DockerContext:
135156 base_building_data : str
136157 building_data : str
137158
159+ # Cached, reproducible tar bytes per (probe: bool). Immutable => thread-safe reuse.
160+ _context_tar_bytes : dict [bool , bytes ]
161+
138162 def __init__ (
139163 self ,
140164 building_data : str | None = None ,
@@ -160,36 +184,48 @@ def __init__(
160184 self .base_building_data = base_building_data
161185 self .building_data = building_data
162186
187+ self ._context_tar_bytes = {}
188+
163189 @staticmethod
164190 def add_bytes (tar : tarfile .TarFile , name : str , data : bytes , mode : int = 0o644 ) -> None :
165191 info = tarfile .TarInfo (name = name )
166192 info .size = len (data )
167193 info .mode = mode
168- info .mtime = 0
194+ info .mtime = 0 # stable for cache keys
169195 info .uid = info .gid = 0
170196 info .uname = info .gname = ""
171197 tar .addfile (info , io .BytesIO (data ))
172198
173- def build_tarball_stream (self , probe : bool = False ) -> io .BytesIO :
174- tar_stream = io .BytesIO ()
175- with tarfile .open (fileobj = tar_stream , mode = "w" ) as tar :
176- # Add Dockerfile
199+ def _build_tarball_bytes (self , probe : bool = False ) -> bytes :
200+ """
201+ Build a reproducible tarball (stable mtimes/owners and deterministic order)
202+ and return its raw bytes for fast reuse across parallel builds.
203+ """
204+ buf = io .BytesIO ()
205+ with tarfile .open (fileobj = buf , mode = "w" ) as tar :
206+ # Deterministic order
177207 DockerContext .add_bytes (tar , "Dockerfile" , self .dockerfile_data .encode ("utf-8" ))
178- # Add entrypoint.sh
179208 DockerContext .add_bytes (tar , "entrypoint.sh" , self .entrypoint_data .encode ("utf-8" ), mode = 0o755 )
180- # Add docker_build_env.sh
181209 DockerContext .add_bytes (tar , "docker_build_env.sh" , self .env_building_data .encode ("utf-8" ), mode = 0o755 )
182-
183- # Add docker_build_base.sh
184210 DockerContext .add_bytes (tar , "docker_build_base.sh" , self .base_building_data .encode ("utf-8" ), mode = 0o755 )
185-
186211 if not probe :
187- # Add docker_build_pkg.sh
188212 DockerContext .add_bytes (tar , "docker_build_pkg.sh" , self .building_data .encode ("utf-8" ), mode = 0o755 )
213+ buf .seek (0 )
214+ return buf .getvalue ()
215+
216+ def _get_context_bytes (self , probe : bool = False ) -> bytes :
217+ """
218+ Return cached tar bytes for the requested probe flag, building once lazily.
219+ """
220+ if probe not in self ._context_tar_bytes :
221+ self ._context_tar_bytes [probe ] = self ._build_tarball_bytes (probe = probe )
222+ return self ._context_tar_bytes [probe ]
189223
190- # Reset the stream position to the beginning
191- tar_stream .seek (0 )
192- return tar_stream
224+ def build_tarball_stream (self , probe : bool = False ) -> io .BytesIO :
225+ """
226+ Backwards-compatible: return a new BytesIO over the cached tar bytes.
227+ """
228+ return io .BytesIO (self ._get_context_bytes (probe = probe ))
193229
194230 def process_image_name (self , image_name : str ) -> tuple [str , str ]:
195231 """Split image name into (repo, target). Target is required."""
@@ -221,7 +257,6 @@ def build_container(
221257 logger .info ("Docker image '%s' found locally." , image_name )
222258 except ImageNotFound :
223259 logger .info ("Docker image '%s' not found locally. Building new image." , image_name )
224- pass # Image doesn't exist or was removed, proceed to build
225260
226261 if not image_exists :
227262 cache_from = None
@@ -230,25 +265,36 @@ def build_container(
230265 build_args = {** build_args , "BASE_IMAGE" : base_image }
231266 cache_from = [base_image ]
232267
268+ if len (build_args ) == 0 and not probe :
269+ raise RuntimeError (f"Docker image '{ image_name } ' not found and no REPO_URL provided for build." )
270+
271+ # Pretty log
233272 if len (build_args ):
234273 build_args_str = " --build-arg " .join (f"{ k } ={ v } " for k , v in build_args .items ())
235- logger .info ("$ docker build -t %s src/datasmith/docker/ --build-arg %s" , image_name , build_args_str )
236- try :
237- client .images .build (
238- fileobj = self .build_tarball_stream (probe = probe ),
239- custom_context = True ,
240- tag = image_name ,
241- buildargs = {** build_args , "BUILDKIT_INLINE_CACHE" : "1" },
242- target = target ,
243- rm = True ,
244- labels = run_labels ,
245- network_mode = os .environ .get ("DOCKER_NETWORK_MODE" , None ),
246- cache_from = cache_from ,
247- )
248- except DockerException :
249- logger .exception ("Failed to build Docker image '%s'" , image_name )
274+ logger .info ("$ docker build -t %s . --build-arg %s" , image_name , build_args_str )
250275 else :
251- raise RuntimeError (f"Docker image '{ image_name } ' not found and no REPO_URL provided for build." )
276+ logger .info ("$ docker build -t %s ." , image_name )
277+
278+ api = _new_api_client (client )
279+ try :
280+ stream = api .build (
281+ fileobj = io .BytesIO (self ._get_context_bytes (probe = probe )),
282+ custom_context = True ,
283+ tag = image_name ,
284+ buildargs = {** build_args , "BUILDKIT_INLINE_CACHE" : "1" },
285+ target = target ,
286+ rm = True ,
287+ labels = run_labels ,
288+ network_mode = os .environ .get ("DOCKER_NETWORK_MODE" , None ),
289+ cache_from = cache_from ,
290+ decode = True ,
291+ pull = False ,
292+ )
293+ # Drain stream to ensure completion
294+ for _ in stream :
295+ pass
296+ except DockerException :
297+ logger .exception ("Failed to build Docker image '%s'" , image_name )
252298
253299 if not client .images .get (image_name ):
254300 raise RuntimeError (f"Image '{ image_name } ' failed to build and is not found." )
@@ -271,6 +317,10 @@ def build_container_streaming( # noqa: C901
271317 SDK-only build with streamed logs, tail capture, and a wall-clock timeout.
272318 Returns a BuildResult and does NOT raise for typical failures (so callers can
273319 report immediately).
320+
321+ Changes vs previous version:
322+ - Reuses a cached, reproducible tarball to avoid per-build tarring & cache drift.
323+ - Uses a fresh low-level API client per call to avoid connection contention in ThreadPools.
274324 """
275325 run_labels = run_labels if run_labels else {}
276326 _ , target = self .process_image_name (image_name )
@@ -299,8 +349,9 @@ def build_container_streaming( # noqa: C901
299349 except ImageNotFound :
300350 logger .info ("Docker image '%s' not found locally. Building." , image_name )
301351
302- # Streamed build via low-level API for better control
303- tar_stream = self .build_tarball_stream (probe = probe )
352+ # Streamed build via fresh low-level API client
353+ api = _new_api_client (client )
354+ tar_bytes = self ._get_context_bytes (probe = probe )
304355 stdout_buf : deque [str ] = deque (maxlen = 2000 ) # chunk-tail buffers
305356 stderr_buf : deque [str ] = deque (maxlen = 2000 )
306357
@@ -318,8 +369,8 @@ def build_container_streaming( # noqa: C901
318369 logger .info ("$ docker build -t %s ." , image_name )
319370
320371 try :
321- stream = client . api .build (
322- fileobj = tar_stream ,
372+ stream = api .build (
373+ fileobj = io . BytesIO ( tar_bytes ) ,
323374 custom_context = True ,
324375 tag = image_name ,
325376 buildargs = {** build_args , "BUILDKIT_INLINE_CACHE" : "1" },
@@ -358,14 +409,12 @@ def build_container_streaming( # noqa: C901
358409 if s :
359410 stdout_buf .append (s )
360411 if "status" in chunk and chunk .get ("progressDetail" ):
361- # Status lines (pulling base layers, etc.)—treat as stdout
362412 s = str (chunk .get ("status" , "" ))
363413 if s :
364414 stdout_buf .append (s + "\n " )
365415 if "error" in chunk or "errorDetail" in chunk :
366416 error_seen = (chunk .get ("error" ) or str (chunk .get ("errorDetail" , "" ))).strip ()
367417 if error_seen :
368- # also track in stderr tail
369418 stderr_buf .append (error_seen + "\n " )
370419 break
371420 except APIError :
@@ -594,7 +643,7 @@ def get_similar(self, key: str | Task) -> list[tuple[Task, DockerContext]]: # n
594643 1) exact match (if present) — returned Task uses the caller's tag
595644 2) other SHAs for owner/repo — returned Tasks use the caller's tag
596645 sorted by |commit_date diff| if available, else by SHA
597- 3) base owner/repo — returned Task uses the caller's tag
646+ 3) base owner/repo — returned Tasks use the caller's tag
598647 """
599648 user_task = self .parse_key (key ) if isinstance (key , str ) else key
600649 canonical = self ._canonicalize (user_task )
0 commit comments