Skip to content

coglet 0.23.1: scalar Path outputs intermittently returned as a one-element array (["https://…"]) #3205

Open

Description

@lucataco

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:

  1. The non-terminal output webhook has the wrong shape for scalar outputs. When a file output is uploaded, coglet sends an output webhook whose output is the accumulated outputs array (["url"]), even though output_is_array == false and the final response is correctly unwrapped to "url".
  2. Webhooks for one prediction aren't ordered. The non-terminal output webhook and the terminal completed webhook are sent from separate tokio::spawned tasks, so the terminal webhook can arrive first. Locally, a stale processing webhook with output: ["url"] arrived after the succeeded webhook 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 50

public_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)

Suggested fixes

  1. Respect the output shape in non-terminal webhooks. When output_is_array is false and the predictor isn't streaming, don't send the accumulated outputs array as output. Either leave output out until the terminal webhook (skip the Output event for scalar file outputs), or render it with the same rules as wrap_outputs.
  2. 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.
  3. Tests. For a scalar -> Path predictor with a webhook and a 202 + Location upload stub, assert over many iterations that no non-terminal webhook has an array output and that completed is 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions