Skip to content

Commit fb6c87f

Browse files
authored
new api (#6)
1 parent 356ac1e commit fb6c87f

2 files changed

Lines changed: 90 additions & 40 deletions

File tree

scratch/scripts/synthesize_contexts.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import argparse
44
import datetime
55
import json
6-
import os
76
import random
87
from concurrent.futures import ThreadPoolExecutor, as_completed
98
from pathlib import Path
@@ -139,7 +138,8 @@ def main(args: argparse.Namespace) -> None:
139138

140139
logger.info("Building base image...")
141140
base_tag = build_base_image(client, DockerContext())
142-
os.environ["DOCKER_CACHE_FROM"] = base_tag
141+
logger.debug("%s", base_tag)
142+
# os.environ["DOCKER_CACHE_FROM"] = base_tag
143143

144144
# Prepare tasks
145145
tasks = prepare_tasks(all_states, args.limit_per_repo, context_registry)
@@ -154,6 +154,7 @@ def main(args: argparse.Namespace) -> None:
154154
k: str(v.replace(" ", "_").replace("'", "").replace('"', "")) for k, v in machine_defaults.items()
155155
}
156156
logger.debug("main: machine_defaults keys=%d", len(machine_defaults))
157+
logger.info("main: Starting work on %d tasks[%d workers]", len(tasks), args.max_workers)
157158

158159
results: list[dict] = []
159160
if args.max_workers < 1:

src/datasmith/docker/context.py

Lines changed: 87 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@
2424
logger = 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+
2748
def 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

Comments
 (0)