Skip to content

Commit 966752e

Browse files
feat: concurrency decorator (#3089)
* feat: add concurrent decorator * fix: bound concurrent max conversion * Update pkg/schema/python/parser_test.go Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> Signed-off-by: Anish Sahoo <anishsahoo2005@gmail.com> * fix: address concurrency PR feedback * fix: address concurrency PR review feedback - build: make predictor metadata parse non-fatal on schema opt-out paths - build: marshal effective config in image label to avoid concurrency drift - schema: detect async via tree-sitter structure instead of text prefix - sdk: raise clear TypeError on positional @Concurrent usage - coglet-python: add worker setup guard tests for COG_MAX_CONCURRENCY - docs: clarify @cog.concurrent version and yaml precedence; regen llms.txt --------- Signed-off-by: Anish Sahoo <anishsahoo2005@gmail.com> Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
1 parent 6cce8fd commit 966752e

25 files changed

Lines changed: 986 additions & 66 deletions

File tree

crates/coglet-python/src/lib.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,25 @@ fn detect_version(py: Python<'_>, build: &BuildInfo) -> VersionInfo {
188188

189189
fn read_max_concurrency() -> usize {
190190
match std::env::var("COG_MAX_CONCURRENCY") {
191-
Ok(val) => val.parse::<usize>().unwrap_or(1),
191+
Ok(val) => match parse_max_concurrency(&val) {
192+
Some(n) => n,
193+
None => {
194+
warn!(value = %val, "Invalid COG_MAX_CONCURRENCY value, defaulting to 1");
195+
1
196+
}
197+
},
192198
Err(_) => 1,
193199
}
194200
}
195201

202+
fn parse_max_concurrency(val: &str) -> Option<usize> {
203+
match val.parse::<usize>() {
204+
Ok(0) => None,
205+
Ok(n) => Some(n),
206+
Err(_) => None,
207+
}
208+
}
209+
196210
fn read_setup_timeout() -> Option<std::time::Duration> {
197211
match std::env::var("COG_SETUP_TIMEOUT") {
198212
Ok(val) => match val.parse::<u64>() {
@@ -542,10 +556,10 @@ async fn run_worker_with_init() -> Result<(), String> {
542556
info!(predictor_ref = %predictor_ref, num_slots, is_train, "Init received, connecting to transport");
543557

544558
let handler = Arc::new(if is_train {
545-
worker_bridge::PythonPredictHandler::new_train(predictor_ref)
559+
worker_bridge::PythonPredictHandler::new_train(predictor_ref, num_slots)
546560
.map_err(|e| format!("Failed to create handler: {}", e))?
547561
} else {
548-
worker_bridge::PythonPredictHandler::new(predictor_ref)
562+
worker_bridge::PythonPredictHandler::new(predictor_ref, num_slots)
549563
.map_err(|e| format!("Failed to create handler: {}", e))?
550564
});
551565

@@ -568,6 +582,26 @@ async fn run_worker_with_init() -> Result<(), String> {
568582
.map_err(|e| format!("Worker error: {}", e))
569583
}
570584

585+
#[cfg(test)]
586+
mod tests {
587+
use super::*;
588+
589+
#[test]
590+
fn parse_max_concurrency_reads_valid_value() {
591+
assert_eq!(parse_max_concurrency("4"), Some(4));
592+
}
593+
594+
#[test]
595+
fn parse_max_concurrency_rejects_invalid_value() {
596+
assert_eq!(parse_max_concurrency("wat"), None);
597+
}
598+
599+
#[test]
600+
fn parse_max_concurrency_rejects_zero() {
601+
assert_eq!(parse_max_concurrency("0"), None);
602+
}
603+
}
604+
571605
// =============================================================================
572606
// Module init
573607
// =============================================================================

crates/coglet-python/src/worker_bridge.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,12 @@ pub struct PythonPredictHandler {
101101
async_loop: Mutex<Option<Py<PyAny>>>,
102102
/// Handle to the asyncio loop thread for joining on shutdown.
103103
async_thread: Mutex<Option<JoinHandle<()>>>,
104+
max_concurrency: usize,
104105
}
105106

106107
impl PythonPredictHandler {
107108
/// Create a handler in prediction mode.
108-
pub fn new(predictor_ref: String) -> Result<Self, SetupError> {
109+
pub fn new(predictor_ref: String, max_concurrency: usize) -> Result<Self, SetupError> {
109110
let (loop_obj, thread) = Self::init_async_loop()?;
110111
Ok(Self {
111112
predictor_ref,
@@ -114,6 +115,7 @@ impl PythonPredictHandler {
114115
mode: HandlerMode::Predict,
115116
async_loop: Mutex::new(Some(loop_obj)),
116117
async_thread: Mutex::new(Some(thread)),
118+
max_concurrency,
117119
})
118120
}
119121

@@ -122,7 +124,7 @@ impl PythonPredictHandler {
122124
/// NOTE: For bug-for-bug compatibility with cog mainline, use new() instead.
123125
/// Cog mainline's training routes incorrectly use a predict-mode worker.
124126
#[allow(dead_code)]
125-
pub fn new_train(predictor_ref: String) -> Result<Self, SetupError> {
127+
pub fn new_train(predictor_ref: String, max_concurrency: usize) -> Result<Self, SetupError> {
126128
let (loop_obj, thread) = Self::init_async_loop()?;
127129
Ok(Self {
128130
predictor_ref,
@@ -131,6 +133,7 @@ impl PythonPredictHandler {
131133
mode: HandlerMode::Train,
132134
async_loop: Mutex::new(Some(loop_obj)),
133135
async_thread: Mutex::new(Some(thread)),
136+
max_concurrency,
134137
})
135138
}
136139

@@ -281,6 +284,11 @@ impl PredictHandler for PythonPredictHandler {
281284

282285
let pred = PythonPredictor::load(py, &self.predictor_ref)
283286
.map_err(|e| SetupError::load(e.to_string()))?;
287+
if self.max_concurrency > 1 && !pred.is_async() {
288+
return Err(SetupError::setup(
289+
"COG_MAX_CONCURRENCY > 1 requires an async run() or predict() method",
290+
));
291+
}
284292

285293
// Detect SDK implementation
286294
let sdk_impl = match py.import("cog") {
@@ -703,3 +711,107 @@ fn output_to_json(output: coglet_core::PredictionOutput) -> serde_json::Value {
703711
coglet_core::PredictionOutput::Stream(v) => serde_json::Value::Array(v),
704712
}
705713
}
714+
715+
#[cfg(test)]
716+
mod tests {
717+
use super::*;
718+
719+
use std::path::PathBuf;
720+
721+
use pyo3::types::PyList;
722+
use tempfile::TempDir;
723+
724+
fn add_python_sdk_path(py: Python<'_>) {
725+
py.run(
726+
c"\
727+
import sys, types
728+
coglet = types.ModuleType('coglet')
729+
coglet.CancelationException = Exception
730+
sys.modules.setdefault('coglet', coglet)
731+
requests = types.ModuleType('requests')
732+
sys.modules.setdefault('requests', requests)
733+
",
734+
None,
735+
None,
736+
)
737+
.expect("failed to install coglet test stub");
738+
739+
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
740+
let sdk_path = manifest_dir
741+
.parent()
742+
.and_then(|p| p.parent())
743+
.expect("crate should live under crates/coglet-python")
744+
.join("python");
745+
let sys = py.import("sys").expect("sys should import");
746+
let path = sys
747+
.getattr("path")
748+
.expect("sys.path should exist")
749+
.cast_into::<PyList>()
750+
.expect("sys.path should be a list");
751+
path.insert(0, sdk_path.to_string_lossy().as_ref())
752+
.expect("failed to prepend SDK path");
753+
}
754+
755+
fn write_predictor(source: &str) -> (TempDir, String) {
756+
let dir = tempfile::tempdir().expect("failed to create temp dir");
757+
let path = dir.path().join("predictor.py");
758+
std::fs::write(&path, source).expect("failed to write test predictor");
759+
let predictor_ref = format!("{}:Predictor", path.display());
760+
(dir, predictor_ref)
761+
}
762+
763+
// Runtime guard: an operator/env override of COG_MAX_CONCURRENCY > 1 must
764+
// fail setup when the predictor is synchronous, mirroring the static
765+
// parser and build-time checks.
766+
#[tokio::test]
767+
async fn setup_rejects_sync_predictor_with_concurrency() {
768+
pyo3::Python::initialize();
769+
Python::attach(add_python_sdk_path);
770+
771+
let (_dir, predictor_ref) = write_predictor(
772+
r#"
773+
from cog import BaseRunner
774+
775+
class Predictor(BaseRunner):
776+
def run(self) -> str:
777+
return "ok"
778+
"#,
779+
);
780+
781+
let handler =
782+
PythonPredictHandler::new(predictor_ref, 2).expect("handler should initialize");
783+
let err = handler
784+
.setup()
785+
.await
786+
.expect_err("setup should fail for a sync predictor with concurrency > 1");
787+
assert!(
788+
err.to_string()
789+
.contains("COG_MAX_CONCURRENCY > 1 requires an async run() or predict() method"),
790+
"unexpected error: {err}"
791+
);
792+
}
793+
794+
// The same guard must allow an async predictor with concurrency > 1.
795+
#[tokio::test]
796+
async fn setup_allows_async_predictor_with_concurrency() {
797+
pyo3::Python::initialize();
798+
Python::attach(add_python_sdk_path);
799+
800+
let (_dir, predictor_ref) = write_predictor(
801+
r#"
802+
from cog import BaseRunner
803+
804+
class Predictor(BaseRunner):
805+
async def run(self) -> str:
806+
return "ok"
807+
"#,
808+
);
809+
810+
let handler =
811+
PythonPredictHandler::new(predictor_ref, 2).expect("handler should initialize");
812+
handler
813+
.setup()
814+
.await
815+
.expect("setup should succeed for an async predictor with concurrency > 1");
816+
}
817+
}

docs/deploy.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,11 @@ curl http://localhost:5001/predictions -X POST \
7676

7777
```json
7878
{
79-
"status": "succeeded",
80-
"output": "data:image/png;base64,...",
81-
"metrics": {
82-
"predict_time": 4.52
83-
}
79+
"status": "succeeded",
80+
"output": "data:image/png;base64,...",
81+
"metrics": {
82+
"predict_time": 4.52
83+
}
8484
}
8585
```
8686

@@ -144,8 +144,8 @@ the response contains a base64-encoded data URL by default:
144144

145145
```json
146146
{
147-
"status": "succeeded",
148-
"output": "data:image/png;base64,iVBORw0KGgo..."
147+
"status": "succeeded",
148+
"output": "data:image/png;base64,iVBORw0KGgo..."
149149
}
150150
```
151151

@@ -172,8 +172,8 @@ contains the uploaded URL instead of a data URL:
172172

173173
```json
174174
{
175-
"status": "succeeded",
176-
"output": "https://example.com/upload/image.png"
175+
"status": "succeeded",
176+
"output": "https://example.com/upload/image.png"
177177
}
178178
```
179179

@@ -215,21 +215,25 @@ to stop.)
215215

216216
## Concurrency
217217

218-
By default, the server processes one run at a time. To enable concurrent runs, set the `concurrency.max` option in `cog.yaml`:
218+
By default, the server processes one run at a time. To enable concurrent runs, make your `run()` method async and decorate it with `@cog.concurrent(max=N)`:
219219

220-
```yaml
221-
concurrency:
222-
max: 4
220+
```py
221+
import cog
222+
223+
class Runner(cog.BaseRunner):
224+
@cog.concurrent(max=4)
225+
async def run(self) -> str:
226+
return "hello world"
223227
```
224228

225-
See the [`cog.yaml` reference](yaml.md#concurrency) for more details.
229+
The deprecated [`concurrency.max`](yaml.md#concurrency) field in `cog.yaml` is still supported and takes precedence over the decorator by baking `COG_MAX_CONCURRENCY` into the image.
226230

227231
## Environment variables
228232

229233
You can configure runtime behavior with environment variables:
230234

231235
- `COG_SETUP_TIMEOUT`: Maximum time in seconds for the `setup()` method (default: no timeout).
232-
- `COG_MAX_CONCURRENCY`: Number of concurrent prediction slots (default: 1).
236+
- `COG_MAX_CONCURRENCY`: Number of concurrent prediction slots (default: 1). Overrides both `@cog.concurrent` and deprecated `cog.yaml` concurrency.
233237

234238
See the [environment variables reference](environment.md) for the full list.
235239

docs/environment.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,18 @@ These variables affect a running model server. Set them in `cog.yaml` under `env
173173

174174
### `COG_MAX_CONCURRENCY`
175175

176-
Controls how many predictions the model server can run concurrently.
176+
Controls how many predictions the model server can run concurrently. This overrides both `@cog.concurrent(max=N)` and the deprecated `concurrency.max` field in `cog.yaml`.
177177

178-
By default, Cog runs one prediction at a time. Invalid values are ignored and the default of `1` is used.
178+
By default, Cog runs one prediction at a time unless the model uses `@cog.concurrent(max=N)`. Invalid values are ignored and the default of `1` is used.
179+
180+
Values greater than `1` require an async `run()` method. This applies even when `COG_MAX_CONCURRENCY` is set as a runtime operator override.
181+
182+
Concurrency is resolved in this order, from highest to lowest precedence:
183+
184+
1. `COG_MAX_CONCURRENCY` set at runtime
185+
2. Deprecated `concurrency.max` in `cog.yaml`, which is baked into the image as `COG_MAX_CONCURRENCY`
186+
3. `@cog.concurrent(max=N)` on the async `run()` method
187+
4. Default: `1`
179188

180189
```console
181190
$ COG_MAX_CONCURRENCY=4 docker run -p 5000:5000 my-model

docs/examples.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ If you want a working project to copy from, start with one of these:
1212
- [`hello-context`](https://github.com/replicate/cog/tree/main/examples/hello-context):
1313
shows how to read prediction context
1414
- [`hello-concurrency`](https://github.com/replicate/cog/tree/main/examples/hello-concurrency):
15-
demonstrates the `concurrency` setting in `cog.yaml`
15+
demonstrates the `@cog.concurrent` decorator
1616
- [`hello-train`](https://github.com/replicate/cog/tree/main/examples/hello-train):
1717
defines a training interface
1818
- [`hello-replicate`](https://github.com/replicate/cog/tree/main/examples/hello-replicate):

docs/http.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ Prefer: respond-async
255255
Endpoints for creating and canceling a prediction idempotently
256256
accept a `prediction_id` parameter in their path.
257257
By default, the server runs one prediction at a time,
258-
but this can be increased with the [`concurrency.max`](yaml.md#concurrency) setting.
258+
but this can be increased with [`@cog.concurrent(max=N)`](python.md#async-runners-and-concurrency).
259259
When all prediction slots are in use, the server returns `409 Conflict`.
260260
The client should ensure prediction slots are available
261261
before creating a new prediction with a different ID.

0 commit comments

Comments
 (0)