Repository navigation
coglet 0.23.1: scalar Path outputs intermittently returned as a one-element array (["https://…"]) #3205
Description
Summary
Since 0.23.1, predictors that return a single Path (schema Output: {type: string, format: uri}) sometimes finish with output: ["https://…/output.wav"] instead of output: "https://…/output.wav". The same input can produce either shape. We see it on several TTS and video models built with 0.23.1 on Replicate. The same models built with 0.23.0 or earlier always return a string.
Two coglet bugs cause this. Both are already in 0.23.0, but 0.23.1 makes them visible:
- The non-terminal
outputwebhook has the wrong shape for scalar outputs. When a file output is uploaded, coglet sends anoutputwebhook whoseoutputis the accumulatedoutputsarray (["url"]), even thoughoutput_is_array == falseand the final response is correctly unwrapped to"url". - Webhooks for one prediction aren't ordered. The non-terminal
outputwebhook and the terminalcompletedwebhook are sent from separatetokio::spawned tasks, so the terminal webhook can arrive first. Locally, a staleprocessingwebhook withoutput: ["url"]arrived after thesucceededwebhook in 10–22% of runs.
A consumer that applies webhooks in arrival order therefore ends with ["url"].
Why 0.23.1: #3197 added X-Prediction-Id to output upload requests. On Replicate, that header makes the upload endpoint accept the file asynchronously (202 Accepted). The prediction then stays open until the upload completes, which gives the late processing webhook from (2) time to be applied after the terminal one. With 0.23.0 the prediction was finalized immediately, so the late webhook was dropped.
Reproduction
Run a real coglet server (pip wheels, no Docker) against a local stub that accepts uploads with 202 + Location (as in coglet's own output_upload_sends_prediction_id_and_preserves_output_url test) and records webhooks in arrival order. Results from 50 sequential predictions, predict() -> Path:
coglet 0.23.1, Output schema: {type: string, format: uri}, 50 runs
p0001511c6ab webhooks in arrival order: [('processing', 'null'), ('processing', 'list[1]'), ('succeeded', 'str')]
p004484f801a webhooks in arrival order: [('processing', 'null'), ('succeeded', 'str'), ('processing', 'list[1]')]
uploads with X-Prediction-Id header: 50/50
non-terminal webhook had output=list[1]: 50/50
a non-terminal webhook arrived AFTER the terminal one: 7/50
last webhook received: ('processing', 'list[1]'): 7/50
coglet 0.23.0, Output schema: {type: string, format: uri}, 50 runs
uploads with X-Prediction-Id header: 0/50
non-terminal webhook had output=list[1]: 50/50
a non-terminal webhook arrived AFTER the terminal one: 5/50
predict.py, .cog/openapi_schema.json and public_repro.py
predict.py
import tempfile
from cog import BasePredictor, Path
class Predictor(BasePredictor):
def predict(self, size_kb: int = 512) -> Path:
print("Generating output...")
out = Path(tempfile.mkdtemp()) / "output.wav"
out.write_bytes(b"\0" * (size_kb * 1024))
print("Generated output")
return out.cog/openapi_schema.json
{
"openapi": "3.0.2",
"info": {"title": "Cog", "version": "0.1.0"},
"paths": {},
"components": {
"schemas": {
"Input": {"type": "object", "title": "Input",
"properties": {"size_kb": {"type": "integer", "title": "Size Kb", "default": 512, "x-order": 0}}},
"Output": {"type": "string", "title": "Output", "format": "uri"}
}
}
}Setup and run:
uv venv --python 3.12 .venv-0.23.1 && uv pip install --python .venv-0.23.1 cog==0.23.1 coglet==0.23.1
python3 public_repro.py .venv-0.23.1/bin/python --runs 50public_repro.py
"""Minimal repro: coglet webhooks for a scalar `-> Path` output.
Runs a real coglet server against a local stub that
* accepts output uploads with `202 Accepted` + `Location` (as in coglet's own tests)
* records every webhook in arrival order.
Usage: public_repro.py PYTHON_FROM_VENV_WITH_COG [--runs N]
"""
import argparse
import json
import os
import subprocess
import sys
import threading
import time
import urllib.request
import uuid
from collections import Counter, defaultdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
STUB_PORT, COG_PORT = 4998, 5056
TERMINAL = {"succeeded", "failed", "canceled"}
lock = threading.Lock()
webhooks = defaultdict(list)
prediction_id_headers = Counter()
def shape(v):
return "null" if v is None else f"list[{len(v)}]" if isinstance(v, list) else type(v).__name__
class Stub(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _read(self):
return self.rfile.read(int(self.headers.get("Content-Length", 0)))
def _reply(self, code, headers=()):
self.send_response(code)
for k, v in headers:
self.send_header(k, v)
self.send_header("Content-Length", "0")
self.end_headers()
def do_PUT(self): # output upload
self._read()
with lock:
prediction_id_headers[bool(self.headers.get("X-Prediction-Id"))] += 1
name = self.path.rsplit("/", 1)[-1]
self._reply(202, [("Location", f"https://example.com/outputs/{uuid.uuid4().hex}/{name}")])
def do_POST(self): # webhook
p = json.loads(self._read())
self._reply(200)
with lock:
webhooks[p["id"]].append((p["status"], shape(p.get("output"))))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("python")
ap.add_argument("--runs", type=int, default=50)
args = ap.parse_args()
srv = ThreadingHTTPServer(("127.0.0.1", STUB_PORT), Stub)
threading.Thread(target=srv.serve_forever, daemon=True).start()
venv_bin = os.path.dirname(os.path.abspath(args.python))
env = dict(os.environ, PORT=str(COG_PORT), COG_PREDICT_TYPE_STUB="predict.py:Predictor",
PATH=f"{venv_bin}{os.pathsep}{os.environ['PATH']}")
proc = subprocess.Popen([args.python, "-m", "cog.server.http", "--upload-url",
f"http://127.0.0.1:{STUB_PORT}/upload/"],
cwd=HERE, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(300):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{COG_PORT}/health-check") as r:
if json.load(r)["status"] == "READY":
break
except Exception:
pass
time.sleep(0.2)
else:
sys.exit("coglet did not become READY")
ver = subprocess.run([args.python, "-c", "import coglet;print(coglet.__version__)"],
capture_output=True, text=True).stdout.strip()
print(f"coglet {ver}, Output schema: {{type: string, format: uri}}, {args.runs} runs")
stats = Counter()
for i in range(args.runs):
pid = f"p{i:03d}{uuid.uuid4().hex[:8]}"
req = urllib.request.Request(
f"http://127.0.0.1:{COG_PORT}/predictions/{pid}", method="PUT",
data=json.dumps({"id": pid, "input": {},
"webhook": f"http://127.0.0.1:{STUB_PORT}/webhook"}).encode(),
headers={"Content-Type": "application/json", "Prefer": "respond-async"})
urllib.request.urlopen(req).read()
deadline = time.time() + 30
while time.time() < deadline:
with lock:
if any(s in TERMINAL for s, _ in webhooks[pid]):
break
time.sleep(0.02)
time.sleep(0.3) # let any straggler webhooks land
with lock:
seq = list(webhooks[pid])
t = next(n for n, (s, _) in enumerate(seq) if s in TERMINAL)
array_nonterminal = any(s not in TERMINAL and o.startswith("list") for s, o in seq)
after_terminal = len(seq) > t + 1
stats["non-terminal webhook had output=list[1]"] += array_nonterminal
stats["a non-terminal webhook arrived AFTER the terminal one"] += after_terminal
stats[f"last webhook received: {seq[-1]}"] += 1
if i < 2 or (after_terminal and stats["shown"] < 2):
stats["shown"] += after_terminal
print(f" {pid} webhooks in arrival order: {seq}")
stats.pop("shown", None)
print(f" uploads with X-Prediction-Id header: {prediction_id_headers[True]}/{sum(prediction_id_headers.values())}")
for k, v in stats.items():
print(f" {k}: {v}/{args.runs}")
finally:
proc.terminate()
proc.wait(10)
srv.shutdown()
if __name__ == "__main__":
main()Root cause (v0.23.1)
- The upload task appends each uploaded URL as a streaming output. This happens for scalar outputs too:
orchestrator.rs#L1192→Prediction::append_output_chunkfiresWebhookEventType::Output(prediction.rs#L511-L518). - For non-terminal states,
build_state_snapshotsends the rawoutputsvector asoutput(prediction.rs#L592-L596), so a scalar output goes out as["url"]. Only the terminal path applieswrap_outputs(orchestrator.rs#L124-L143). Outputevents are never throttled and are sent fire-and-forget from their own task (webhook.rs#L154,webhook.rs#L186-L198). Microseconds later the upload waiter callsset_succeeded(orchestrator.rs#L1277-L1295), which spawns the terminal webhook as another task (prediction.rs#L567-L576). Nothing orders the two requests, so the terminal webhook often wins. The likely cause is tokio's LIFO slot: the woken waiter task, and then the terminal-webhook task, run before the earlier-spawned output-webhook task.orchestrator.rs#L54(Fail predictions when output uploads fail #3197) addsX-Prediction-Id, which enables the asynchronous upload path described above.
Suggested fixes
- Respect the output shape in non-terminal webhooks. When
output_is_arrayis false and the predictor isn't streaming, don't send the accumulatedoutputsarray asoutput. Either leaveoutputout until the terminal webhook (skip theOutputevent for scalar file outputs), or render it with the same rules aswrap_outputs. - Deliver each prediction's webhooks in order. For example, use one ordered queue and sender task per
WebhookSender, and send the terminal webhook only after earlier non-terminal requests have finished or been dropped. A monotonically increasing sequence number in the payload would also let receivers drop stale updates. - Tests. For a scalar
-> Pathpredictor with a webhook and a202 + Locationupload stub, assert over many iterations that no non-terminal webhook has an arrayoutputand thatcompletedis always the last webhook delivered.
Workaround: build affected models with cog 0.23.0.
Environment
- cog / coglet 0.23.1 (
9b9f6339), also compared with 0.23.0 - Repro: macOS arm64, Python 3.12, PyPI wheels. Also seen in production on linux/amd64.
Activity