Repository navigation
fix(artifact): bound docker save and isolate file upload attempts #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,12 +10,18 @@ | |
|
|
||
| from agent_env.artifact.artifact import Artifact, _write_twin | ||
| from agent_env.config import get_config | ||
| from agent_env.store.base import NotFoundError, ObjectAlreadyExistsError | ||
|
|
||
| if TYPE_CHECKING: | ||
| from agent_env.artifact.store import ArtifactStore | ||
| from agent_env.bundle.authoring import AuthoringContext | ||
| from agent_env.entity.export import ExportContext | ||
|
|
||
|
|
||
| # How many reserved versions one upload tries before giving up on the version path. | ||
| _UPLOAD_VERSION_ATTEMPTS = 3 | ||
|
|
||
|
|
||
| class FileArtifact(Artifact): | ||
| """A generic file artifact that stores any file in the object store. | ||
|
|
||
|
|
@@ -69,19 +75,10 @@ def put( | |
| if content_type is None: | ||
| content_type = "application/octet-stream" | ||
|
|
||
| version = store.next_version(id) | ||
|
|
||
| # put_object_file, not put_object: a hosted store uploads a file in parts, where a | ||
| # single put is capped (5 GB on S3). It streams from disk, so large files don't get | ||
| # loaded into RAM either. | ||
| stored_url = store.put_object_file( | ||
| artifact_type="file", | ||
| id=id, | ||
| version=version, | ||
| object_name=filename, | ||
| file_path=file_path, | ||
| content_type=content_type, | ||
| ) | ||
| stored_url = cls._upload(store, id, filename, content_type, file_path=file_path) | ||
|
|
||
| # The object is keyed by the reserved version; the document takes the next free one, the same unless a | ||
| # concurrent write took it first. | ||
|
|
@@ -108,16 +105,7 @@ def put_bytes( | |
| from agent_env.artifact.store import get_artifact_store | ||
|
|
||
| store = get_artifact_store() | ||
| version = store.next_version(id) | ||
|
|
||
| stored_url = store.put_object( | ||
| artifact_type="file", | ||
| id=id, | ||
| version=version, | ||
| object_name=filename, | ||
| data=content, | ||
| content_type=content_type, | ||
| ) | ||
| stored_url = cls._upload(store, id, filename, content_type, content=content) | ||
|
|
||
| # The object is keyed by the reserved version; the document takes the next free one, the same unless a | ||
| # concurrent write took it first. | ||
|
|
@@ -130,6 +118,61 @@ def put_bytes( | |
| ) | ||
| return store.put_document(instance) | ||
|
|
||
| @classmethod | ||
| def _upload( | ||
| cls, | ||
| store: "ArtifactStore", | ||
| id: str, | ||
| filename: str, | ||
| content_type: str, | ||
| *, | ||
| file_path: str | None = None, | ||
| content: bytes | None = None, | ||
| ) -> str: | ||
| """Upload to the reserved version's write-once path, working around what is already there. | ||
|
|
||
| A collision with a published version means a concurrent writer won the reservation: | ||
| reserve the next version and retry. A collision with no document behind it is an | ||
| orphan an earlier attempt left when its document insert failed; write-once objects | ||
| are never deleted or overwritten, so this attempt uploads under a prefix of its own | ||
| (``ArtifactStore.attempt_prefix``) instead of staying blocked by the orphan. | ||
| """ | ||
| version = store.next_version(id) | ||
| for _ in range(_UPLOAD_VERSION_ATTEMPTS): | ||
| try: | ||
| if file_path is not None: | ||
| return store.put_object_file( | ||
| artifact_type="file", | ||
| id=id, | ||
| version=version, | ||
| object_name=filename, | ||
| file_path=file_path, | ||
| content_type=content_type, | ||
|
Comment on lines
+143
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Two concurrent This leaves the concurrency fix incomplete. Use an attempt-specific path before uploading files, or make S3 file creation atomic. Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: src/agent_env/artifact/artifacts/file.py
Line: 143-150
Comment:
**Concurrent S3 uploads overwrite files**
Two concurrent `FileArtifact.put()` calls can still choose the same version and pass the existence check in `S3ObjectStore._put_file()` before either upload finishes. That method uploads without a create-only condition, so neither call raises `ObjectAlreadyExistsError` and the new fallback never runs. Both documents can publish with the same `object_url`, leaving one artifact with the other writer's contents.
This leaves the concurrency fix incomplete. Use an attempt-specific path before uploading files, or make S3 file creation atomic.
**Knowledge Base Used:**
- [Artifact management and delivery](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/artifact-management.md)
- [Storage, objects, documents, and secrets](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/storage-and-secrets.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| ) | ||
| return store.put_object( | ||
| artifact_type="file", | ||
| id=id, | ||
| version=version, | ||
| object_name=filename, | ||
| data=content, | ||
| content_type=content_type, | ||
| ) | ||
| except ObjectAlreadyExistsError: | ||
| try: | ||
| store.get(id, version) | ||
| except NotFoundError: | ||
| break | ||
| version = store.next_version(id) | ||
| else: | ||
| raise ObjectAlreadyExistsError( | ||
| f"every reserved version of artifact {id!r} was published by a concurrent writer" | ||
| ) | ||
| prefix = store.attempt_prefix("file", id) | ||
| objects = get_config().get_object_store_to_write(prefix, id) | ||
| if file_path is not None: | ||
| return objects.put_file_at(prefix + filename, file_path, content_type) | ||
| return objects.put(objects.get_object_key(prefix + filename), content, content_type) | ||
|
|
||
| @classmethod | ||
| def from_toml(cls, data: dict, ctx: AuthoringContext) -> Self: | ||
| """Write the folder's one file (``put_attempt``); ``description`` defaults to the file's name.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """DockerImageArtifact.put's ``docker save`` streaming: bounded, drained, and reaped. | ||
|
|
||
| Regression guard: put used to read the save process's stdout to EOF before waiting | ||
| on it, with stderr never drained. A save that filled its stderr pipe deadlocked, and | ||
| a stalled save blocked the read itself, so the timeout in the wait never fired. | ||
| The docker CLI is a PATH stub; the image store and the push are stubbed at the same | ||
| boundaries the other docker image tests use. No Docker daemon is involved. | ||
| """ | ||
|
|
||
| import gzip | ||
| import os | ||
| import time | ||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
|
|
||
| import agent_env.artifact.artifacts.docker_image as docker_image | ||
| from agent_env.artifact.artifacts.docker_image import DockerImageArtifact | ||
| from agent_env.config import get_config | ||
|
|
||
| _STUB = """#!/bin/sh | ||
| if [ "$1" = "save" ]; then | ||
| case "$FAKE_DOCKER_MODE" in | ||
| flood) python3 -c 'import sys; sys.stderr.write("x" * 300000); sys.stdout.write("FAKETAR")' ;; | ||
| stall) exec sleep 60 ;; | ||
| fail) echo "boom: no such image" >&2; exit 3 ;; | ||
| *) printf 'FAKETAR' ;; | ||
| esac | ||
| exit 0 | ||
| fi | ||
| exit 0 | ||
| """ | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def stubbed_put(local_stores, tmp_path, monkeypatch): | ||
| bindir = tmp_path / "bin" | ||
| bindir.mkdir() | ||
| stub = bindir / "docker" | ||
| stub.write_text(_STUB) | ||
| stub.chmod(0o755) | ||
| monkeypatch.setenv("PATH", f"{bindir}:{os.environ['PATH']}") | ||
| monkeypatch.setattr(docker_image, "push_local_image", lambda *a, **k: None) | ||
| image_store = SimpleNamespace( | ||
| image_ref=lambda repo, tag: f"localhost:5000/{repo}:{tag}", | ||
| ensure_repository=lambda repo: None, | ||
| ) | ||
| monkeypatch.setattr(get_config(), "get_image_store_for", lambda id: image_store) | ||
| monkeypatch.setattr(DockerImageArtifact, "DOCKER_SAVE_TIMEOUT_SECONDS", 2) | ||
| return monkeypatch | ||
|
|
||
|
|
||
| def _put(monkeypatch, mode, artifact_id): | ||
| monkeypatch.setenv("FAKE_DOCKER_MODE", mode) | ||
| return DockerImageArtifact.put(artifact_id, description="d", image_name="img:latest") | ||
|
|
||
|
|
||
| def test_save_with_saturated_stderr_completes(stubbed_put): | ||
| art = _put(stubbed_put, "flood", "img-flood") | ||
|
|
||
| assert gzip.decompress(art.load()) == b"FAKETAR" | ||
|
|
||
|
|
||
| def test_stalled_save_is_killed_at_the_timeout(stubbed_put): | ||
| start = time.monotonic() | ||
| with pytest.raises(RuntimeError, match="timed out after 2 seconds"): | ||
| _put(stubbed_put, "stall", "img-stall") | ||
| assert time.monotonic() - start < 20 | ||
|
|
||
|
|
||
| def test_failed_save_reports_its_stderr(stubbed_put): | ||
| with pytest.raises(RuntimeError, match="boom: no such image"): | ||
| _put(stubbed_put, "fail", "img-fail") | ||
|
|
||
|
|
||
| def test_save_streams_the_archive_to_the_object_store(stubbed_put): | ||
| art = _put(stubbed_put, "ok", "img-ok") | ||
|
|
||
| assert art.version == 1 | ||
| assert gzip.decompress(art.load()) == b"FAKETAR" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
drainer.join(timeout=5)uses an unnamed five-second limit. The repository requires magic numbers to be stored as descriptive class or instance variables. The new stderr read size has the same issue, and the new module-level limits also do not follow that requirement.Put these settings on the relevant classes and reference them from the helpers. This repository requirement must be satisfied before merging.
Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)
Learned From
scaleapi/scaleapi#126388
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!