⚡ p3: native_async + sse_specs dispatch in handler pipeline (ADR-088/089)

swadhinbiswas committed 2 months ago
d8c16dd
1 parent 8d3c5dc
commit d8c16dd
1 file changed
+265 -123
Showing 1 changed file+265-123
▼crates/justapi-py/src/native/handlers.rs
+265-123
103
103
serialize_response(py, res, helper)
104
104
}
105
105
106
+/// Build the Python `Request` + multipart form dict and invoke the handler.
107
+/// Shared by the sync (GIL-pool) and async (parallel pool) dispatch paths
108
+/// (ADR-087).
109
+#[allow(clippy::too_many_arguments)]
110
+fn dispatch_python_handler(
111
+ py: Python<'_>,
112
+ handlers: &Arc<Vec<Py<PyAny>>>,
113
+ schemas: &Arc<Vec<Option<Py<PyAny>>>>,
114
+ handler_id: usize,
115
+ path_params: &[(String, String)],
116
+ query_string: &[u8],
117
+ headers: &[(Vec<u8>, Vec<u8>)],
118
+ body: &[u8],
119
+ db_url: Option<&str>,
120
+ trace_ctx: Option<(String, String)>,
121
+ multipart_form_res: Option<justapi_core::multipart::MultipartForm>,
122
+ method: &str,
123
+ path: &str,
124
+ scheme: String,
125
+ client: Option<(String, u16)>,
126
+ app: Option<Py<PyAny>>,
127
+ needs_request: bool,
128
+ http_version: String,
129
+ auth_claims: Option<String>,
130
+) -> NativeResponse {
131
+ let mut form_dict_py: Option<pyo3::Py<pyo3::types::PyDict>> = None;
132
+ if let Some(form) = multipart_form_res {
133
+ let d = pyo3::types::PyDict::new(py);
134
+ for (k, v) in form.fields.iter() {
135
+ d.set_item(k, v).ok();
136
+ }
137
+ for f in form.files.iter() {
138
+ let headers_dict = pyo3::types::PyDict::new(py);
139
+ for (k, v) in f.headers.iter() {
140
+ headers_dict.set_item(k, v).ok();
141
+ }
142
+ let upload_file = crate::multipart::UploadFile::new(
143
+ f.filename.clone().unwrap_or_default(),
144
+ f.content_type.clone().unwrap_or_default(),
145
+ f.size,
146
+ headers_dict.into(),
147
+ f.temp_path.clone(),
148
+ );
149
+ let Ok(p) = pyo3::Bound::new(py, upload_file) else {
150
+ continue;
151
+ };
152
+ d.set_item(&f.field_name, p).ok();
153
+ }
154
+ form_dict_py = Some(d.into());
155
+ }
156
+
157
+ call_python_handler(
158
+ py,
159
+ &handlers[handler_id],
160
+ schemas[handler_id].as_ref(),
161
+ path_params,
162
+ query_string,
163
+ headers,
164
+ body,
165
+ db_url,
166
+ trace_ctx,
167
+ form_dict_py,
168
+ method,
169
+ path,
170
+ scheme,
171
+ client,
172
+ app,
173
+ needs_request,
174
+ http_version,
175
+ auth_claims,
176
+ )
177
+}
178
+
106
179
#[allow(clippy::too_many_arguments)]
107
180
pub(crate) fn call_python_handler(
108
181
py: Python<'_>,
521
594
/// serialize the resolved value with the same fast serializer the sync path
522
595
/// uses. Runs on a `spawn_blocking` thread so no worker thread is tied up.
523
596
async fn resolve_async_response(py_future: Py<PyAny>) -> Result<NativeResponse, anyhow::Error> {
524
- // Block on the future's .result(). If the coroutine raised (HTTPException,
525
- // RequestValidationError, ...), the exception surfaces as a PyErr from
526
- // .result() — convert it with the same logic as the sync Err path.
527
- let outcome: Result<Py<PyAny>, pyo3::PyErr> = tokio::task::spawn_blocking(move || {
528
- Python::attach(|py| py_future.bind(py).call_method0("result").map(|v| v.unbind()))
597
+ // Callback-driven completion: register a `_DoneNotifier` on the
598
+ // concurrent.futures.Future so we can await completion on the tokio
599
+ // runtime without blocking a thread (no spawn_blocking) or polling.
600
+ // The callback fires on the asyncio loop thread the instant the coroutine
601
+ // finishes. This removes one thread hop vs the old spawn_blocking path.
602
+ let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
603
+ // Register the callback on a spawn_blocking thread so no GIL attach ever
604
+ // happens on the server threads (which would contend with sync handlers).
605
+ // Returns the future so the result-read closure can use it.
606
+ let register = tokio::task::spawn_blocking(move || {
607
+ Python::attach(|py| -> Result<Py<PyAny>, anyhow::Error> {
608
+ let notifier = pyo3::Bound::new(
609
+ py,
610
+ crate::native::types::DoneNotifier { tx: std::sync::Mutex::new(Some(done_tx)) },
611
+ )
612
+ .map_err(|e| anyhow::anyhow!("failed to create done notifier: {}", e))?;
613
+ let fut = py_future.bind(py);
614
+ fut.call_method1("add_done_callback", (notifier,))
615
+ .map_err(|e| anyhow::anyhow!("async handler callback error: {}", e))?;
616
+ Ok(py_future)
617
+ })
618
+ });
619
+ let py_future =
620
+ register.await.map_err(|e| anyhow::anyhow!("async callback join error: {}", e))??;
621
+
622
+ // Wait for the coroutine to finish (pure tokio await, no thread blocked).
623
+ done_rx.await.map_err(|_| anyhow::anyhow!("async handler dropped"))?;
624
+
625
+ // Read + serialize the result on spawn_blocking so the GIL attaches never
626
+ // contend with sync handlers on the server threads (the coroutine is
627
+ // already done, so .result() returns instantly).
628
+ tokio::task::spawn_blocking(move || {
629
+ Python::attach(|py| {
630
+ let outcome: Result<Py<PyAny>, pyo3::PyErr> =
631
+ py_future.bind(py).call_method0("result").map(|v| v.unbind());
632
+ match outcome {
633
+ Ok(res) => {
634
+ let helper = get_helper(py);
635
+ handle_ok_result(py, res.bind(py).into(), helper)
636
+ }
637
+ Err(e) => handle_py_error(py, e),
638
+ }
639
+ })
529
640
})
530
641
.await
531
- .map_err(|e| anyhow::anyhow!("async handler join error: {}", e))?;
532
-
533
- let nr = Python::attach(|py| match outcome {
534
- Ok(res) => {
535
- let helper = get_helper(py);
536
- handle_ok_result(py, res.bind(py).into(), helper)
537
- }
538
- Err(e) => handle_py_error(py, e),
539
- });
540
- Ok(nr)
642
+ .map_err(|e| anyhow::anyhow!("async handler join error: {}", e))
541
643
}
542
644
543
645
pub(crate) fn nr_to_response(nr: NativeResponse) -> hyper::Response<ResponseBody> {
748
850
app: Option<Py<PyAny>>,
749
851
needs_request: Arc<Vec<bool>>,
750
852
native: Arc<Vec<bool>>,
853
+ _native_async: Arc<Vec<bool>>,
751
854
crud_specs: CrudConfig,
855
+ sse_specs: Arc<Vec<Option<(u64, u64)>>>,
752
856
schema_validators: Arc<Vec<Option<justapi_core::validate::CompiledValidator>>>,
753
857
max_body_size: usize,
754
858
) -> justapi_core::middleware::HandlerFn<B>
782
886
.to_string();
783
887
let needs_request_clone = needs_request.clone();
784
888
let native_clone = native.clone();
889
+ #[cfg(Py_GIL_DISABLED)]
890
+ let native_async_clone = _native_async.clone();
785
891
let crud_specs_clone = crud_specs.clone();
892
+ let sse_specs_clone = sse_specs.clone();
786
893
let schema_validators_clone = schema_validators.clone();
787
894
Box::pin(async move {
788
895
let method = req.method().clone();
824
931
let (body_bytes, multipart_form_res) = if is_multipart {
825
932
let ct = content_type.unwrap();
826
933
match justapi_core::multipart::parse_multipart(req_body, &ct).await {
827
- Ok(form) => (Bytes::new(), Some(Ok::<_, anyhow::Error>(form))),
934
+ Ok(form) => (Bytes::new(), Some(form)),
828
935
Err(e) => {
829
936
let msg = e.to_string();
830
937
if msg.contains("exceeds maximum size") {
906
1013
return Ok(nr_to_response(res));
907
1014
}
908
1015
1016
+ // Rust-native SSE stream (ADR-088): serve events generated
1017
+ // entirely in Rust — no Python handler, no GIL, no pump.
1018
+ if let Some(Some((count, interval_ms))) = sse_specs_clone.get(handler_id) {
1019
+ return Ok(justapi_core::server::sse_stream_response(*count, *interval_ms));
1020
+ }
1021
+
909
1022
// Rust-native CRUD (ADR-056 Step C): validate body + run INSERT /
910
1023
// SELECT / UPDATE / DELETE entirely in Rust, no GIL/Python hop. The
911
1024
// operation is inferred from the request method; the body was already
998
1111
let db_url_str = db_url.clone();
999
1112
let trace_ctx = justapi_core::trace_context::get_current_trace_context();
1000
1113
1001
- let nr = crate::gil_pool::run_python(move |py| {
1002
- let mut form_dict_py: Option<pyo3::Py<pyo3::types::PyDict>> = None;
1003
- if let Some(Ok(form)) = multipart_form_res {
1004
- let d = pyo3::types::PyDict::new(py);
1005
- for (k, v) in form.fields.iter() {
1006
- d.set_item(k, v).ok();
1007
- }
1008
- for f in form.files.iter() {
1009
- let headers_dict = pyo3::types::PyDict::new(py);
1010
- for (k, v) in f.headers.iter() {
1011
- headers_dict.set_item(k, v).ok();
1012
- }
1013
- let upload_file = crate::multipart::UploadFile::new(
1014
- f.filename.clone().unwrap_or_default(),
1015
- f.content_type.clone().unwrap_or_default(),
1016
- f.size,
1017
- headers_dict.into(),
1018
- f.temp_path.clone(),
1019
- );
1020
- let Ok(p) = pyo3::Bound::new(py, upload_file) else {
1021
- continue;
1022
- };
1023
- d.set_item(&f.field_name, p).ok();
1024
- }
1025
- form_dict_py = Some(d.into());
1026
- }
1027
-
1028
- call_python_handler(
1029
- py,
1030
- &handlers_clone[handler_id],
1031
- schemas_clone[handler_id].as_ref(),
1032
- &path_params,
1033
- &query_string,
1034
- &headers,
1035
- &body_bytes,
1036
- db_url_str.as_deref(),
1037
- trace_ctx,
1038
- form_dict_py,
1039
- method.as_str(),
1040
- &path,
1041
- req_scheme.clone(),
1042
- req_client.clone(),
1043
- app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1044
- needs_request_clone[handler_id],
1045
- http_version.clone(),
1046
- auth_claims,
1047
- )
1048
- })
1049
- .await
1050
- .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?;
1114
+ // `@native_async` + free-threaded CPython: dispatch in TRUE
1115
+ // parallel (no GIL to thrash) — the GIL pool scales to the
1116
+ // hardware. On GIL-locked builds, native_async uses the standard
1117
+ // pool + callback resolution (the optimal path there).
1118
+ #[cfg(Py_GIL_DISABLED)]
1119
+ let is_parallel = native_async_clone.get(handler_id).copied().unwrap_or(false);
1120
+ #[cfg(not(Py_GIL_DISABLED))]
1121
+ let is_parallel = false;
1122
+
1123
+ let nr = if is_parallel {
1124
+ crate::gil_pool::run_python_parallel(move |py| {
1125
+ dispatch_python_handler(
1126
+ py,
1127
+ &handlers_clone,
1128
+ &schemas_clone,
1129
+ handler_id,
1130
+ &path_params,
1131
+ &query_string,
1132
+ &headers,
1133
+ &body_bytes,
1134
+ db_url_str.as_deref(),
1135
+ trace_ctx,
1136
+ multipart_form_res.clone(),
1137
+ method.as_str(),
1138
+ &path,
1139
+ req_scheme.clone(),
1140
+ req_client.clone(),
1141
+ app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1142
+ needs_request_clone[handler_id],
1143
+ http_version.clone(),
1144
+ auth_claims,
1145
+ )
1146
+ })
1147
+ .await
1148
+ .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?
1149
+ } else {
1150
+ crate::gil_pool::run_python(move |py| {
1151
+ dispatch_python_handler(
1152
+ py,
1153
+ &handlers_clone,
1154
+ &schemas_clone,
1155
+ handler_id,
1156
+ &path_params,
1157
+ &query_string,
1158
+ &headers,
1159
+ &body_bytes,
1160
+ db_url_str.as_deref(),
1161
+ trace_ctx,
1162
+ multipart_form_res.clone(),
1163
+ method.as_str(),
1164
+ &path,
1165
+ req_scheme.clone(),
1166
+ req_client.clone(),
1167
+ app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1168
+ needs_request_clone[handler_id],
1169
+ http_version.clone(),
1170
+ auth_claims,
1171
+ )
1172
+ })
1173
+ .await
1174
+ .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?
1175
+ };
1051
1176
1052
- // Async handlers: the GIL worker returned the future without
1053
- // blocking. Await it here (spawn_blocking, GIL released while
1054
- // waiting) so concurrent async requests interleave.
1177
+ // Async handlers: await the future here via the callback-driven
1178
+ // resolution (GIL released while waiting), so concurrent async
1179
+ // requests interleave.
1055
1180
let nr = match nr.body {
1056
1181
NativeBody::Async(fut) => resolve_async_response(fut).await?,
1057
1182
_ => nr,
1083
1208
app: Option<Py<PyAny>>,
1084
1209
needs_request: Arc<Vec<bool>>,
1085
1210
native: Arc<Vec<bool>>,
1211
+ _native_async: Arc<Vec<bool>>,
1212
+ sse_specs: Arc<Vec<Option<(u64, u64)>>>,
1086
1213
schema_validators: Arc<Vec<Option<justapi_core::validate::CompiledValidator>>>,
1087
1214
max_body_size: usize,
1088
1215
) -> justapi_core::middleware::HandlerFn<B>
1108
1235
let http_version = http_version.clone();
1109
1236
let needs_request_clone = needs_request.clone();
1110
1237
let native_clone = native.clone();
1238
+ #[cfg(Py_GIL_DISABLED)]
1239
+ let native_async_clone = _native_async.clone();
1240
+ let sse_specs_clone = sse_specs.clone();
1111
1241
let schema_validators_clone = schema_validators.clone();
1112
1242
Box::pin(async move {
1113
1243
let method = req.method().clone();
1153
1283
Err(e) => return Err(anyhow::anyhow!("Body error: {}", e)),
1154
1284
};
1155
1285
1156
- let multipart_form_res: Option<
1157
- Result<justapi_core::multipart::MultipartForm, anyhow::Error>,
1158
- > = if is_multipart {
1286
+ let multipart_form_res: Option<justapi_core::multipart::MultipartForm> = if is_multipart
1287
+ {
1159
1288
let ct = content_type.unwrap();
1160
1289
match justapi_core::multipart::parse_multipart(
1161
1290
http_body_util::Full::new(body_bytes.clone()),
1163
1292
)
1164
1293
.await
1165
1294
{
1166
- Ok(form) => Some(Ok::<_, anyhow::Error>(form)),
1295
+ Ok(form) => Some(form),
1167
1296
Err(e) => {
1168
1297
let msg = e.to_string();
1169
1298
if msg.contains("exceeds maximum size") {
1200
1329
1201
1330
let handler_id = *matched.handler;
1202
1331
1332
+ // Rust-native SSE stream (ADR-088): serve events entirely in Rust.
1333
+ if let Some(Some((count, interval_ms))) = sse_specs_clone.get(handler_id) {
1334
+ return Ok(justapi_core::server::sse_stream_response(*count, *interval_ms));
1335
+ }
1336
+
1203
1337
// Native fast path: validate the body in Rust and echo it back as the
1204
1338
// response, with no blocking-pool thread hop, no GIL acquisition, and
1205
1339
// no Python handler call.
1250
1384
1251
1385
let method_clone = method.to_string();
1252
1386
let path_clone = path.clone();
1253
- let nr = crate::gil_pool::run_python(move |py| {
1254
- let mut form_dict_py: Option<pyo3::Py<pyo3::types::PyDict>> = None;
1255
- if let Some(Ok(form)) = &multipart_form_res {
1256
- let d = pyo3::types::PyDict::new(py);
1257
- for (k, v) in form.fields.iter() {
1258
- d.set_item(k, v).ok();
1259
- }
1260
- for f in form.files.iter() {
1261
- let headers_dict = pyo3::types::PyDict::new(py);
1262
- for (k, v) in f.headers.iter() {
1263
- headers_dict.set_item(k, v).ok();
1264
- }
1265
- let upload_file = crate::multipart::UploadFile::new(
1266
- f.filename.clone().unwrap_or_default(),
1267
- f.content_type.clone().unwrap_or_default(),
1268
- f.size,
1269
- headers_dict.into(),
1270
- f.temp_path.clone(),
1271
- );
1272
- let Ok(p) = pyo3::Bound::new(py, upload_file) else {
1273
- continue;
1274
- };
1275
- d.set_item(&f.field_name, p).ok();
1276
- }
1277
- form_dict_py = Some(d.into());
1278
- }
1279
-
1280
- call_python_handler(
1281
- py,
1282
- &handlers_clone[handler_id],
1283
- schemas_clone[handler_id].as_ref(),
1284
- &path_params,
1285
- &query_string,
1286
- &headers,
1287
- &body_bytes,
1288
- db_url_str.as_deref(),
1289
- trace_ctx,
1290
- form_dict_py,
1291
- method_clone.as_str(),
1292
- &path_clone,
1293
- req_scheme.clone(),
1294
- req_client.clone(),
1295
- app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1296
- needs_request_clone[handler_id],
1297
- http_version.clone(),
1298
- None, // auth_claims: test client has no middleware
1299
- )
1300
- })
1301
- .await
1302
- .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?;
1387
+ #[cfg(Py_GIL_DISABLED)]
1388
+ let is_parallel = native_async_clone.get(handler_id).copied().unwrap_or(false);
1389
+ #[cfg(not(Py_GIL_DISABLED))]
1390
+ let is_parallel = false;
1391
+
1392
+ let nr = if is_parallel {
1393
+ crate::gil_pool::run_python_parallel(move |py| {
1394
+ dispatch_python_handler(
1395
+ py,
1396
+ &handlers_clone,
1397
+ &schemas_clone,
1398
+ handler_id,
1399
+ &path_params,
1400
+ &query_string,
1401
+ &headers,
1402
+ &body_bytes,
1403
+ db_url_str.as_deref(),
1404
+ trace_ctx,
1405
+ multipart_form_res.clone(),
1406
+ method_clone.as_str(),
1407
+ &path_clone,
1408
+ req_scheme.clone(),
1409
+ req_client.clone(),
1410
+ app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1411
+ needs_request_clone[handler_id],
1412
+ http_version.clone(),
1413
+ None,
1414
+ )
1415
+ })
1416
+ .await
1417
+ .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?
1418
+ } else {
1419
+ crate::gil_pool::run_python(move |py| {
1420
+ dispatch_python_handler(
1421
+ py,
1422
+ &handlers_clone,
1423
+ &schemas_clone,
1424
+ handler_id,
1425
+ &path_params,
1426
+ &query_string,
1427
+ &headers,
1428
+ &body_bytes,
1429
+ db_url_str.as_deref(),
1430
+ trace_ctx,
1431
+ multipart_form_res.clone(),
1432
+ method_clone.as_str(),
1433
+ &path_clone,
1434
+ req_scheme.clone(),
1435
+ req_client.clone(),
1436
+ app.as_ref().as_ref().map(|a| a.clone_ref(py)),
1437
+ needs_request_clone[handler_id],
1438
+ http_version.clone(),
1439
+ None,
1440
+ )
1441
+ })
1442
+ .await
1443
+ .map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?
1444
+ };
1303
1445
1304
1446
// Async handlers: await the future here (GIL released while
1305
1447
// waiting), mirroring make_native_handler.
1306
1448