Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@ jobs:
- name: Run ruff formatting check
run: uv run ruff format --check .

golang-checks:
name: Go Quality Checks and Tests
runs-on: ubuntu-latest

defaults:
run:
working-directory: ./code-interpreter-go

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: code-interpreter-go/go.mod

- name: Check formatting
run: test -z "$(gofmt -l .)"

- name: Build
run: go build ./...

- name: Run go vet
run: go vet ./...

- name: Run tests
run: go test ./...

- name: Pull executor Docker image
run: docker pull onyxdotapp/python-executor-sci:latest

- name: Run Docker executor end-to-end test
run: go test ./internal/executor -run TestDockerExecutorEndToEnd -v
env:
CODE_INTERPRETER_GO_E2E: "1"

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
Expand Down
113 changes: 113 additions & 0 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,116 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

# Each platform builds on a NATIVE runner (no QEMU emulation) and pushes
# per-target images by digest; the merge job below assembles the multi-arch
# manifest lists. Targets are defined in code-interpreter-go/docker-bake.hcl.
build-code-interpreter-go:
name: Build Code Interpreter Go (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: build-executor
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Login to dhi.io for hardened base images
uses: docker/login-action@v3
with:
registry: dhi.io
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push by digest
id: bake
uses: docker/bake-action@v5
with:
workdir: ./code-interpreter-go
set: |
*.platform=${{ matrix.platform }}
*.output=type=image,name=${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go,push-by-digest=true,name-canonical=true,push=true
full.cache-from=type=gha,scope=code-interpreter-go-full-${{ matrix.arch }}
full.cache-to=type=gha,scope=code-interpreter-go-full-${{ matrix.arch }},mode=max
slim.cache-from=type=gha,scope=code-interpreter-go-slim-${{ matrix.arch }}
slim.cache-to=type=gha,scope=code-interpreter-go-slim-${{ matrix.arch }},mode=max
env:
PUSH_BY_DIGEST: "1"

- name: Export digests
run: |
mkdir -p /tmp/digests
cat <<'METADATA' > /tmp/digests/${{ matrix.arch }}.json
${{ steps.bake.outputs.metadata }}
METADATA

- name: Upload digests
uses: actions/upload-artifact@v4
with:
name: code-interpreter-go-digests-${{ matrix.arch }}
path: /tmp/digests/${{ matrix.arch }}.json
retention-days: 1

merge-code-interpreter-go:
name: Merge Code Interpreter Go Manifests
runs-on: ubuntu-latest
needs: build-code-interpreter-go

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: code-interpreter-go-digests-*
path: /tmp/digests
merge-multiple: true

- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Read version from config
id: version
run: echo "version=$(grep -m1 'ServiceVersion = ' code-interpreter-go/internal/config/config.go | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"

- name: Create multi-arch manifests
run: |
REPO="${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go"
VERSION="${{ steps.version.outputs.version }}"
digest() { jq -r --arg t "$1" '.[$t]["containerimage.digest"]' "/tmp/digests/$2.json"; }

docker buildx imagetools create \
-t "$REPO:$VERSION" -t "$REPO:latest" \
"$REPO@$(digest full amd64)" "$REPO@$(digest full arm64)"

docker buildx imagetools create \
-t "$REPO:$VERSION-slim" -t "$REPO:latest-slim" \
"$REPO@$(digest slim amd64)" "$REPO@$(digest slim arm64)"

- name: Inspect published manifests
run: |
REPO="${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go"
docker buildx imagetools inspect "$REPO:${{ steps.version.outputs.version }}"
docker buildx imagetools inspect "$REPO:${{ steps.version.outputs.version }}-slim"
94 changes: 94 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Python vs Go service benchmarks

Measured comparison of the two code-interpreter implementations
(`code-interpreter/`, Python/FastAPI/uvicorn vs `code-interpreter-go/`,
Go/net/http) to inform the migration decision.

## Methodology

`harness/` spawns each service as a native process with identical
configuration (single instance, access logs suppressed on both, unique file
storage dir per run) and measures:

- **Cold start**: process spawn → first `200` from `GET /v1/files`, polled
every 2 ms. 11 runs each, first discarded (page cache / `.pyc` warmup).
- **CPU-to-ready**: `utime+stime` from `/proc/<pid>/stat` at readiness.
- **Memory**: `VmRSS`/`VmHWM` from `/proc/<pid>/status` after a 3 s idle
settle, and again after the load phases.
- **API-layer latency/throughput**: identical HTTP client (keep-alive,
N workers, fixed wall-clock duration) driving storage and validation
endpoints, which exercise routing, (de)serialization, validation, and file
I/O without needing an executor backend.

Environment: 24-CPU shared Linux host (noisy neighbors — absolute numbers
vary run to run; ratios are stable). Two full runs are reported as ranges.
The sandbox lacks `CAP_SYS_ADMIN`, so no Docker daemon could run:
**end-to-end `/v1/execute` latency (container spin-up dominated) is not
measured here** — the Go repo's CI runs a real-daemon e2e test, and both
implementations pay the same daemon-side container costs; they differ in
orchestration overhead (Python spawns ~6 docker-CLI subprocesses per
execution, Go makes Engine API calls over one socket), which this harness
cannot isolate without a daemon.

Reproduce:

```bash
(cd code-interpreter && uv sync --locked)
(cd code-interpreter-go && CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w" -o /tmp/code-interpreter-api ./cmd/code-interpreter-api)
cd benchmarks/harness && go run .
python3 benchmarks/imagesizes.py # registry-reported compressed image sizes
```

## Results (2026-07, two runs, median values shown as ranges)

### Startup and memory

| Metric | Python | Go | Go advantage |
|---|---|---|---|
| Cold start to first served request | 271–763 ms | 21–30 ms | **13–25× faster** |
| CPU time to readiness | 0.26–0.73 s | 0.02–0.03 s | **~13–24× less** |
| Idle RSS | 48 MiB | 31 MiB | **~36% less** |
| RSS after load burst | 52 MiB | 43 MiB | ~17% less |

### API-layer throughput (single instance)

| Endpoint (workers) | Python req/s | Go req/s | p50 latency (Py → Go) | Speedup |
|---|---|---|---|---|
| download 100 KiB (c=1) | 1 114–2 112 | 3 709–4 083 | 0.42–0.62 ms → 0.19 ms | ~2–3.5× |
| download 100 KiB (c=16) | 1 227–1 717 | 8 330–9 870 | 7.7–11.4 ms → 1.3–1.5 ms | **~6–8×** |
| list files (c=16) | 517–590 | 14 859–18 179 | 25–27 ms → 0.6–0.8 ms | **~25–35×** |
| execute validation path (c=1) | 880–1 160 | 9 694–11 355 | 0.6–0.7 ms → 0.07 ms | **~8–13×** |
| upload 100 KiB (c=4) | 292–477 | 1 711–2 269 | 7.7–11.7 ms → 1.6–2.0 ms | ~4–6× |

The Python service plateaus under concurrency (GIL + threadpool: c=16
download throughput barely exceeds c=1); the Go service scales with cores.
Matching Go's c=16 throughput with uvicorn workers would take roughly 6–8
processes at ~48 MiB each (~300–400 MiB) vs one 43 MiB Go process.

### Sizes

Registry-reported compressed sizes (Docker Hub manifests, linux/amd64):

| Artifact | Size | Source |
|---|---|---|
| `onyxdotapp/code-interpreter:latest` (Python, with Docker daemon) | **194 MB** | measured (registry) |
| — of which docker-ce layer | 104 MB | measured (largest layer) |
| — of which `python:3.11-slim` base | 45 MB | measured (registry) |
| — of which app deps/venv + uv layers | ~38 MB | measured (remaining layers) |
| `debian:bookworm-slim` / `trixie-slim` base | 28 / 30 MB | measured (registry) |
| Go service binary (stripped, gzipped) | 13 MB | measured |
| **Go `-slim` image (base + binary + ca-certs/passwd)** | **~45 MB** | derived |
| **Go default image (adds the same docker-ce layer)** | **~150 MB** | derived |

On-disk application payload: Python needs the CPython runtime (84 MB) plus a
129 MB venv; the Go service is a single 46 MB static binary.

## Not measured

- End-to-end `/v1/execute` (needs a Docker daemon; dominated by container
create/start either way). Run the harness on a Docker-capable host to add
this; CI's `TestDockerExecutorEndToEnd` covers correctness.
- Kubernetes-backend performance.
- Uncompressed on-disk image sizes (needs a daemon to build; compressed
pull sizes above are what registries report and networks transfer).
3 changes: 3 additions & 0 deletions benchmarks/harness/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/onyx-dot-app/python-sandbox/benchmarks/harness

go 1.26.3
Loading
Loading