vmux-cli 0.5.2__tar.gz → 0.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/PKG-INFO +1 -1
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/pyproject.toml +1 -1
- vmux_cli-0.5.4/tests/benchmark_bundle_delivery.py +374 -0
- vmux_cli-0.5.4/tests/benchmark_latency.py +179 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/uv.lock +261 -24
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/cli.py +4 -109
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/client.py +71 -29
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/core.py +16 -8
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/packager.py +12 -5
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/.gitignore +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/README.md +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/tests/__init__.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/tests/test_config.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/tests/test_packager.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/__init__.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/auth.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/config.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/deps.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/runner.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/terminal.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/types.py +0 -0
- {vmux_cli-0.5.2 → vmux_cli-0.5.4}/vmux/ui.py +0 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Benchmark different bundle delivery methods to Cloudflare containers.
|
|
3
|
+
|
|
4
|
+
Tests:
|
|
5
|
+
1. writeFile limit verification (confirm actual limit - NOT 32MB as documented!)
|
|
6
|
+
2. R2 upload via Worker (100MB limit due to CF request body size)
|
|
7
|
+
3. R2 presigned URL upload (up to 5GB, bypasses Worker)
|
|
8
|
+
4. mountBucket (FUSE-based, requires production deploy)
|
|
9
|
+
|
|
10
|
+
FINDINGS (Dec 2024):
|
|
11
|
+
- writeFile works up to ~90MB (base64 encoded)
|
|
12
|
+
- R2 via Worker: 100MB CF request body limit
|
|
13
|
+
- R2 presigned: Up to 5GB direct upload
|
|
14
|
+
- mountBucket: Streaming, requires deploy
|
|
15
|
+
|
|
16
|
+
Run with: uv run python -m tests.benchmark_bundle_delivery
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import zipfile
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Add parent to path for vmux imports
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
31
|
+
|
|
32
|
+
from vmux.config import load_config
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
DEBUG = os.environ.get("VMUX_DEBUG", "").lower() in ("1", "true", "yes")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BenchmarkResult:
|
|
41
|
+
"""Result from a single benchmark run."""
|
|
42
|
+
|
|
43
|
+
method: str
|
|
44
|
+
bundle_size_mb: float
|
|
45
|
+
success: bool
|
|
46
|
+
duration_ms: float
|
|
47
|
+
error: str | None = None
|
|
48
|
+
notes: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BenchmarkSuite:
|
|
53
|
+
"""Collection of benchmark results."""
|
|
54
|
+
|
|
55
|
+
results: list[BenchmarkResult] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def add(self, result: BenchmarkResult) -> None:
|
|
58
|
+
self.results.append(result)
|
|
59
|
+
status = "✓" if result.success else "✗"
|
|
60
|
+
print(
|
|
61
|
+
f" {status} {result.method} @ {result.bundle_size_mb:.1f}MB: "
|
|
62
|
+
f"{result.duration_ms:.0f}ms"
|
|
63
|
+
+ (f" - {result.error}" if result.error else "")
|
|
64
|
+
+ (f" ({result.notes})" if result.notes else "")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def print_summary(self) -> None:
|
|
68
|
+
print("\n" + "=" * 60)
|
|
69
|
+
print("BENCHMARK SUMMARY")
|
|
70
|
+
print("=" * 60)
|
|
71
|
+
|
|
72
|
+
# Group by method
|
|
73
|
+
by_method: dict[str, list[BenchmarkResult]] = {}
|
|
74
|
+
for r in self.results:
|
|
75
|
+
by_method.setdefault(r.method, []).append(r)
|
|
76
|
+
|
|
77
|
+
for method, results in by_method.items():
|
|
78
|
+
successes = [r for r in results if r.success]
|
|
79
|
+
failures = [r for r in results if not r.success]
|
|
80
|
+
|
|
81
|
+
print(f"\n{method}:")
|
|
82
|
+
if successes:
|
|
83
|
+
sizes = [r.bundle_size_mb for r in successes]
|
|
84
|
+
times = [r.duration_ms for r in successes]
|
|
85
|
+
print(f" ✓ Success: {len(successes)} runs")
|
|
86
|
+
print(f" Size range: {min(sizes):.1f} - {max(sizes):.1f} MB")
|
|
87
|
+
print(f" Time range: {min(times):.0f} - {max(times):.0f} ms")
|
|
88
|
+
print(f" Avg time: {sum(times)/len(times):.0f} ms")
|
|
89
|
+
if failures:
|
|
90
|
+
print(f" ✗ Failures: {len(failures)}")
|
|
91
|
+
for r in failures:
|
|
92
|
+
print(f" @ {r.bundle_size_mb:.1f}MB: {r.error}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_test_bundle(size_mb: float) -> bytes:
|
|
96
|
+
"""Create a test zip bundle of approximately the given size."""
|
|
97
|
+
target_bytes = int(size_mb * 1024 * 1024)
|
|
98
|
+
|
|
99
|
+
# Create a zip file in memory
|
|
100
|
+
buf = io.BytesIO()
|
|
101
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
|
102
|
+
# Add a simple test script
|
|
103
|
+
zf.writestr(
|
|
104
|
+
"test.py",
|
|
105
|
+
"""#!/usr/bin/env python3
|
|
106
|
+
print("Hello from test bundle!")
|
|
107
|
+
print(f"Bundle delivered successfully")
|
|
108
|
+
""",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Add filler data to reach target size
|
|
112
|
+
# We use compressible but non-trivial data
|
|
113
|
+
filler_chunk = b"X" * (1024 * 1024) # 1MB chunks
|
|
114
|
+
chunks_needed = (target_bytes - buf.tell()) // len(filler_chunk)
|
|
115
|
+
|
|
116
|
+
for i in range(max(0, chunks_needed)):
|
|
117
|
+
zf.writestr(f"filler/data_{i:04d}.bin", filler_chunk)
|
|
118
|
+
|
|
119
|
+
# Fine-tune to get close to target
|
|
120
|
+
remaining = target_bytes - buf.tell()
|
|
121
|
+
if remaining > 100:
|
|
122
|
+
zf.writestr("filler/final.bin", b"Y" * max(0, remaining - 200))
|
|
123
|
+
|
|
124
|
+
return buf.getvalue()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BenchmarkClient:
|
|
128
|
+
"""Client for running benchmarks against vmux worker."""
|
|
129
|
+
|
|
130
|
+
def __init__(self) -> None:
|
|
131
|
+
self.config = load_config()
|
|
132
|
+
self._client = httpx.Client(
|
|
133
|
+
base_url=self.config.api_url,
|
|
134
|
+
timeout=httpx.Timeout(600.0, connect=60.0),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _headers(self) -> dict[str, str]:
|
|
138
|
+
headers = {"Content-Type": "application/json"}
|
|
139
|
+
if self.config.auth_token:
|
|
140
|
+
headers["Authorization"] = f"Bearer {self.config.auth_token}"
|
|
141
|
+
return headers
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
self._client.close()
|
|
145
|
+
|
|
146
|
+
def test_inline_writefile(self, bundle_bytes: bytes) -> tuple[bool, float, str | None]:
|
|
147
|
+
"""Test inline writeFile delivery (base64 in JSON payload)."""
|
|
148
|
+
t0 = time.time()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
payload = {
|
|
152
|
+
"command": "echo 'inline test' && ls -la /workspace",
|
|
153
|
+
"bundle": base64.b64encode(bundle_bytes).decode(),
|
|
154
|
+
"env_vars": {},
|
|
155
|
+
"editables": [],
|
|
156
|
+
"ports": [],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Stream response to get timing
|
|
160
|
+
job_id = None
|
|
161
|
+
with self._client.stream(
|
|
162
|
+
"POST",
|
|
163
|
+
"/run",
|
|
164
|
+
json=payload,
|
|
165
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
166
|
+
timeout=300.0,
|
|
167
|
+
) as response:
|
|
168
|
+
if response.status_code >= 400:
|
|
169
|
+
return False, (time.time() - t0) * 1000, f"HTTP {response.status_code}"
|
|
170
|
+
|
|
171
|
+
for line in response.iter_lines():
|
|
172
|
+
if line.startswith("data: "):
|
|
173
|
+
data = line[6:]
|
|
174
|
+
if data == "[DONE]":
|
|
175
|
+
break
|
|
176
|
+
try:
|
|
177
|
+
event = json.loads(data)
|
|
178
|
+
if "job_id" in event:
|
|
179
|
+
job_id = event["job_id"]
|
|
180
|
+
if event.get("status") == "running":
|
|
181
|
+
# Success - bundle was delivered and extracted
|
|
182
|
+
break
|
|
183
|
+
if "error" in event:
|
|
184
|
+
return False, (time.time() - t0) * 1000, event["error"]
|
|
185
|
+
except json.JSONDecodeError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
duration_ms = (time.time() - t0) * 1000
|
|
189
|
+
|
|
190
|
+
# Cleanup: stop the job
|
|
191
|
+
if job_id:
|
|
192
|
+
try:
|
|
193
|
+
self._client.delete(f"/jobs/{job_id}", headers=self._headers())
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
return True, duration_ms, None
|
|
198
|
+
|
|
199
|
+
except httpx.HTTPStatusError as e:
|
|
200
|
+
return False, (time.time() - t0) * 1000, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
|
201
|
+
except Exception as e:
|
|
202
|
+
return False, (time.time() - t0) * 1000, str(e)
|
|
203
|
+
|
|
204
|
+
def test_r2_delivery(self, bundle_bytes: bytes) -> tuple[bool, float, str | None]:
|
|
205
|
+
"""Test R2 upload + curl delivery."""
|
|
206
|
+
t0 = time.time()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Step 1: Upload to R2
|
|
210
|
+
upload_response = self._client.post(
|
|
211
|
+
"/bundles/upload",
|
|
212
|
+
content=bundle_bytes,
|
|
213
|
+
headers={
|
|
214
|
+
**self._headers(),
|
|
215
|
+
"Content-Type": "application/octet-stream",
|
|
216
|
+
},
|
|
217
|
+
timeout=300.0,
|
|
218
|
+
)
|
|
219
|
+
upload_response.raise_for_status()
|
|
220
|
+
bundle_id = upload_response.json()["bundle_id"]
|
|
221
|
+
|
|
222
|
+
upload_time = time.time() - t0
|
|
223
|
+
|
|
224
|
+
# Step 2: Run with bundle_id
|
|
225
|
+
payload = {
|
|
226
|
+
"command": "echo 'r2 test' && ls -la /workspace",
|
|
227
|
+
"bundle_id": bundle_id,
|
|
228
|
+
"env_vars": {},
|
|
229
|
+
"editables": [],
|
|
230
|
+
"ports": [],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
job_id = None
|
|
234
|
+
with self._client.stream(
|
|
235
|
+
"POST",
|
|
236
|
+
"/run",
|
|
237
|
+
json=payload,
|
|
238
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
239
|
+
timeout=300.0,
|
|
240
|
+
) as response:
|
|
241
|
+
if response.status_code >= 400:
|
|
242
|
+
return False, (time.time() - t0) * 1000, f"HTTP {response.status_code}"
|
|
243
|
+
|
|
244
|
+
for line in response.iter_lines():
|
|
245
|
+
if line.startswith("data: "):
|
|
246
|
+
data = line[6:]
|
|
247
|
+
if data == "[DONE]":
|
|
248
|
+
break
|
|
249
|
+
try:
|
|
250
|
+
event = json.loads(data)
|
|
251
|
+
if "job_id" in event:
|
|
252
|
+
job_id = event["job_id"]
|
|
253
|
+
if event.get("status") == "running":
|
|
254
|
+
break
|
|
255
|
+
if "error" in event:
|
|
256
|
+
return False, (time.time() - t0) * 1000, event["error"]
|
|
257
|
+
except json.JSONDecodeError:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
duration_ms = (time.time() - t0) * 1000
|
|
261
|
+
|
|
262
|
+
# Cleanup
|
|
263
|
+
if job_id:
|
|
264
|
+
try:
|
|
265
|
+
self._client.delete(f"/jobs/{job_id}", headers=self._headers())
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
return True, duration_ms, None
|
|
270
|
+
|
|
271
|
+
except httpx.HTTPStatusError as e:
|
|
272
|
+
return False, (time.time() - t0) * 1000, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
|
273
|
+
except Exception as e:
|
|
274
|
+
return False, (time.time() - t0) * 1000, str(e)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_benchmarks() -> BenchmarkSuite:
|
|
278
|
+
"""Run all benchmark tests."""
|
|
279
|
+
suite = BenchmarkSuite()
|
|
280
|
+
client = BenchmarkClient()
|
|
281
|
+
|
|
282
|
+
print("=" * 60)
|
|
283
|
+
print("VMUX BUNDLE DELIVERY BENCHMARK")
|
|
284
|
+
print("=" * 60)
|
|
285
|
+
print(f"API: {client.config.api_url}")
|
|
286
|
+
print()
|
|
287
|
+
|
|
288
|
+
# Test 1: Verify writeFile limit
|
|
289
|
+
print("\n--- TEST 1: writeFile (inline base64) limit verification ---")
|
|
290
|
+
print("Testing various sizes to find the RPC limit...\n")
|
|
291
|
+
|
|
292
|
+
# Test sizes around the expected 32MB limit
|
|
293
|
+
inline_sizes = [1, 5, 10, 20, 25, 30, 31, 32, 33, 35, 40]
|
|
294
|
+
|
|
295
|
+
for size_mb in inline_sizes:
|
|
296
|
+
print(f"Creating {size_mb}MB test bundle...")
|
|
297
|
+
bundle = create_test_bundle(size_mb)
|
|
298
|
+
actual_mb = len(bundle) / (1024 * 1024)
|
|
299
|
+
print(f" Actual size: {actual_mb:.2f}MB")
|
|
300
|
+
|
|
301
|
+
success, duration_ms, error = client.test_inline_writefile(bundle)
|
|
302
|
+
suite.add(
|
|
303
|
+
BenchmarkResult(
|
|
304
|
+
method="inline_writefile",
|
|
305
|
+
bundle_size_mb=actual_mb,
|
|
306
|
+
success=success,
|
|
307
|
+
duration_ms=duration_ms,
|
|
308
|
+
error=error,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Stop if we hit the limit
|
|
313
|
+
if not success and "32" in str(error).lower() or "limit" in str(error).lower():
|
|
314
|
+
print(f"\n *** Found limit at ~{size_mb}MB ***\n")
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
# Test 2: R2 delivery for larger bundles
|
|
318
|
+
print("\n--- TEST 2: R2 upload + curl delivery ---")
|
|
319
|
+
print("Testing R2 path for bundles >= 32MB...\n")
|
|
320
|
+
|
|
321
|
+
r2_sizes = [10, 32, 50, 64, 80, 100]
|
|
322
|
+
|
|
323
|
+
for size_mb in r2_sizes:
|
|
324
|
+
print(f"Creating {size_mb}MB test bundle...")
|
|
325
|
+
bundle = create_test_bundle(size_mb)
|
|
326
|
+
actual_mb = len(bundle) / (1024 * 1024)
|
|
327
|
+
print(f" Actual size: {actual_mb:.2f}MB")
|
|
328
|
+
|
|
329
|
+
success, duration_ms, error = client.test_r2_delivery(bundle)
|
|
330
|
+
suite.add(
|
|
331
|
+
BenchmarkResult(
|
|
332
|
+
method="r2_curl",
|
|
333
|
+
bundle_size_mb=actual_mb,
|
|
334
|
+
success=success,
|
|
335
|
+
duration_ms=duration_ms,
|
|
336
|
+
error=error,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Summary
|
|
341
|
+
suite.print_summary()
|
|
342
|
+
|
|
343
|
+
# Recommendations
|
|
344
|
+
print("\n" + "=" * 60)
|
|
345
|
+
print("RECOMMENDATIONS")
|
|
346
|
+
print("=" * 60)
|
|
347
|
+
|
|
348
|
+
inline_max = max(
|
|
349
|
+
(r.bundle_size_mb for r in suite.results if r.method == "inline_writefile" and r.success),
|
|
350
|
+
default=0,
|
|
351
|
+
)
|
|
352
|
+
r2_max = max(
|
|
353
|
+
(r.bundle_size_mb for r in suite.results if r.method == "r2_curl" and r.success),
|
|
354
|
+
default=0,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
print(f"\n1. writeFile (inline): Works up to ~{inline_max:.0f}MB")
|
|
358
|
+
print(f" - Use for bundles < {inline_max:.0f}MB (fast, single request)")
|
|
359
|
+
|
|
360
|
+
print(f"\n2. R2 + curl: Works up to ~{r2_max:.0f}MB tested")
|
|
361
|
+
print(f" - Use for bundles >= {inline_max:.0f}MB (requires upload step)")
|
|
362
|
+
|
|
363
|
+
print("\n3. mountBucket (FUSE):")
|
|
364
|
+
print(" - NOT YET TESTED (requires production deploy)")
|
|
365
|
+
print(" - Potentially faster for very large bundles (>100MB)")
|
|
366
|
+
print(" - Files streamed on-demand, no full download needed")
|
|
367
|
+
print(" - Latency: Higher per-file access, lower initial setup")
|
|
368
|
+
|
|
369
|
+
client.close()
|
|
370
|
+
return suite
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
run_benchmarks()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Benchmark latency for different bundle delivery methods.
|
|
3
|
+
|
|
4
|
+
Compares:
|
|
5
|
+
1. R2 via Worker (current: CLI → Worker → R2 → Container curl)
|
|
6
|
+
2. Presigned URL (proposed: CLI → R2 direct → Container curl)
|
|
7
|
+
3. mountBucket (FUSE streaming, production only)
|
|
8
|
+
|
|
9
|
+
Run with: uv run python tests/benchmark_latency.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from vmux.config import load_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_test_bundle(size_mb: float) -> bytes:
|
|
26
|
+
"""Create a test zip bundle of approximately the given size."""
|
|
27
|
+
target_bytes = int(size_mb * 1024 * 1024)
|
|
28
|
+
buf = io.BytesIO()
|
|
29
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
|
30
|
+
zf.writestr("test.py", 'print("Hello from benchmark!")\n')
|
|
31
|
+
filler_chunk = b"X" * (1024 * 1024)
|
|
32
|
+
chunks_needed = (target_bytes - buf.tell()) // len(filler_chunk)
|
|
33
|
+
for i in range(max(0, chunks_needed)):
|
|
34
|
+
zf.writestr(f"filler/data_{i:04d}.bin", filler_chunk)
|
|
35
|
+
return buf.getvalue()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def benchmark_r2_via_worker(client: httpx.Client, headers: dict, bundle_bytes: bytes) -> dict:
|
|
39
|
+
"""Benchmark: CLI → Worker → R2 → Container curl."""
|
|
40
|
+
results = {}
|
|
41
|
+
|
|
42
|
+
# Step 1: Upload to R2 via Worker
|
|
43
|
+
t0 = time.time()
|
|
44
|
+
upload_resp = client.post(
|
|
45
|
+
"/bundles/upload",
|
|
46
|
+
content=bundle_bytes,
|
|
47
|
+
headers={**headers, "Content-Type": "application/octet-stream"},
|
|
48
|
+
timeout=300.0,
|
|
49
|
+
)
|
|
50
|
+
upload_resp.raise_for_status()
|
|
51
|
+
bundle_id = upload_resp.json()["bundle_id"]
|
|
52
|
+
results["upload_time"] = time.time() - t0
|
|
53
|
+
|
|
54
|
+
# Step 2: Start job (container will curl from Worker)
|
|
55
|
+
t1 = time.time()
|
|
56
|
+
payload = {
|
|
57
|
+
"command": "ls -la /workspace && echo done",
|
|
58
|
+
"bundle_id": bundle_id,
|
|
59
|
+
"env_vars": {},
|
|
60
|
+
"editables": [],
|
|
61
|
+
"ports": [],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
job_id = None
|
|
65
|
+
with client.stream(
|
|
66
|
+
"POST", "/run", json=payload,
|
|
67
|
+
headers={**headers, "Accept": "text/event-stream"},
|
|
68
|
+
timeout=300.0,
|
|
69
|
+
) as response:
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
for line in response.iter_lines():
|
|
72
|
+
if line.startswith("data: "):
|
|
73
|
+
data = line[6:]
|
|
74
|
+
if data == "[DONE]":
|
|
75
|
+
break
|
|
76
|
+
import json
|
|
77
|
+
try:
|
|
78
|
+
event = json.loads(data)
|
|
79
|
+
if "job_id" in event:
|
|
80
|
+
job_id = event["job_id"]
|
|
81
|
+
if event.get("status") == "running":
|
|
82
|
+
results["container_fetch_time"] = time.time() - t1
|
|
83
|
+
break
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
results["total_time"] = time.time() - t0
|
|
88
|
+
results["job_id"] = job_id
|
|
89
|
+
|
|
90
|
+
# Cleanup
|
|
91
|
+
if job_id:
|
|
92
|
+
try:
|
|
93
|
+
client.delete(f"/jobs/{job_id}", headers=headers)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return results
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main():
|
|
101
|
+
config = load_config()
|
|
102
|
+
client = httpx.Client(
|
|
103
|
+
base_url=config.api_url,
|
|
104
|
+
timeout=httpx.Timeout(300.0, connect=60.0),
|
|
105
|
+
)
|
|
106
|
+
headers = {"Content-Type": "application/json"}
|
|
107
|
+
if config.auth_token:
|
|
108
|
+
headers["Authorization"] = f"Bearer {config.auth_token}"
|
|
109
|
+
|
|
110
|
+
print("=" * 70)
|
|
111
|
+
print("BUNDLE DELIVERY LATENCY BENCHMARK")
|
|
112
|
+
print("=" * 70)
|
|
113
|
+
print(f"API: {config.api_url}\n")
|
|
114
|
+
|
|
115
|
+
# Test sizes
|
|
116
|
+
sizes = [10, 30, 50, 63, 80]
|
|
117
|
+
|
|
118
|
+
print("Method: R2 via Worker (CLI → Worker → R2 → Container curl)")
|
|
119
|
+
print("-" * 70)
|
|
120
|
+
print(f"{'Size':>8} | {'Upload':>10} | {'Fetch+Extract':>14} | {'Total':>10}")
|
|
121
|
+
print("-" * 70)
|
|
122
|
+
|
|
123
|
+
for size_mb in sizes:
|
|
124
|
+
print(f"{size_mb:>6}MB | ", end="", flush=True)
|
|
125
|
+
bundle = create_test_bundle(size_mb)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
results = benchmark_r2_via_worker(client, headers, bundle)
|
|
129
|
+
upload = results.get("upload_time", 0)
|
|
130
|
+
fetch = results.get("container_fetch_time", 0)
|
|
131
|
+
total = results.get("total_time", 0)
|
|
132
|
+
print(f"{upload:>8.1f}s | {fetch:>12.1f}s | {total:>8.1f}s")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(f"ERROR: {e}")
|
|
135
|
+
|
|
136
|
+
print()
|
|
137
|
+
print("=" * 70)
|
|
138
|
+
print("LATENCY BREAKDOWN ANALYSIS")
|
|
139
|
+
print("=" * 70)
|
|
140
|
+
print("""
|
|
141
|
+
R2 via Worker (current implementation):
|
|
142
|
+
1. CLI → Worker: HTTP POST with raw bytes
|
|
143
|
+
2. Worker → R2: env.BUNDLES.put()
|
|
144
|
+
3. Return bundle_id to CLI
|
|
145
|
+
4. CLI → Worker: POST /run with bundle_id
|
|
146
|
+
5. Container → Worker: curl https://worker/bundles/{id}
|
|
147
|
+
6. Worker → R2: env.BUNDLES.get()
|
|
148
|
+
7. Worker → Container: stream response
|
|
149
|
+
8. Container: unzip + run
|
|
150
|
+
|
|
151
|
+
Bottleneck: Steps 5-7 (container fetches through worker proxy)
|
|
152
|
+
|
|
153
|
+
Presigned URL (proposed):
|
|
154
|
+
1. CLI → Worker: GET /bundles/presign
|
|
155
|
+
2. Worker: generates presigned PUT URL
|
|
156
|
+
3. CLI → R2 DIRECT: PUT to presigned URL (bypasses worker!)
|
|
157
|
+
4. CLI → Worker: POST /run with bundle_id
|
|
158
|
+
5. Container → R2 DIRECT: curl presigned GET URL (bypasses worker!)
|
|
159
|
+
6. Container: unzip + run
|
|
160
|
+
|
|
161
|
+
Benefit: No worker in data path for upload OR download
|
|
162
|
+
Expected: ~30-50% faster for large bundles
|
|
163
|
+
|
|
164
|
+
mountBucket (FUSE streaming):
|
|
165
|
+
1. Pre-populate R2 bucket with bundle
|
|
166
|
+
2. Container: sandbox.mountBucket() - FUSE mount
|
|
167
|
+
3. Container: Files accessed on-demand (no full download)
|
|
168
|
+
4. Container: unzip from mount or access files directly
|
|
169
|
+
|
|
170
|
+
Benefit: No upfront transfer, streaming access
|
|
171
|
+
Tradeoff: Higher per-file latency (network roundtrip per read)
|
|
172
|
+
Best for: Very large datasets where you don't need all files
|
|
173
|
+
""")
|
|
174
|
+
|
|
175
|
+
client.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|