@@ -427,6 +427,146 @@ impl ProducerClient {
427427 Ok ( res)
428428 }
429429
430+ /// Stream output chunks as soon as each consumer has completed them.
431+ pub fn stream_submission_chunks ( & self , submission_id : SubmissionId ) -> PyChunksIter {
432+ self . streaming_submission_chunks ( submission_id)
433+ }
434+
435+ fn streaming_submission_chunks ( & self , submission_id : SubmissionId ) -> PyChunksIter {
436+ let client = self . client . clone ( ) ;
437+ let object_store_client = self . object_store_client . clone ( ) ;
438+ let stream = futures:: stream:: unfold (
439+ (
440+ client,
441+ object_store_client,
442+ submission_id,
443+ u63:: new ( 0 ) ,
444+ None ,
445+ Duration :: from_millis ( 10 ) ,
446+ ) ,
447+ |( client, object_store_client, submission_id, index, prefix, interval) | async move {
448+ let mut interval = interval;
449+ loop {
450+ let status = match client. get_submission ( submission_id. into ( ) ) . await {
451+ Ok ( Some ( status) ) => status,
452+ Ok ( None ) => {
453+ return Some ( (
454+ Err ( StreamingChunkError :: SubmissionNotFound ) ,
455+ (
456+ client,
457+ object_store_client,
458+ submission_id,
459+ index,
460+ prefix,
461+ interval,
462+ ) ,
463+ ) ) ;
464+ }
465+ Err ( error) => {
466+ return Some ( (
467+ Err ( StreamingChunkError :: Internal ( error) ) ,
468+ (
469+ client,
470+ object_store_client,
471+ submission_id,
472+ index,
473+ prefix,
474+ interval,
475+ ) ,
476+ ) ) ;
477+ }
478+ } ;
479+
480+ match status {
481+ submission:: SubmissionStatus :: InProgress ( submission) => {
482+ let prefix = prefix. clone ( ) . or ( submission. prefix ) ;
483+ if index < submission. chunks_done . into ( ) {
484+ let prefix = prefix
485+ . expect ( "in-progress submissions have an object-store prefix" ) ;
486+ let result = object_store_client
487+ . retrieve_chunk ( & prefix, index. into ( ) , ChunkType :: Output )
488+ . await
489+ . map_err ( StreamingChunkError :: Retrieval ) ;
490+ return Some ( (
491+ result,
492+ (
493+ client,
494+ object_store_client,
495+ submission_id,
496+ index + u63:: new ( 1 ) ,
497+ Some ( prefix) ,
498+ interval,
499+ ) ,
500+ ) ) ;
501+ }
502+ }
503+ submission:: SubmissionStatus :: Completed ( submission) => {
504+ let prefix = prefix. clone ( ) . or ( submission. prefix ) ;
505+ if index < submission. chunks_total . into ( ) {
506+ let prefix = prefix
507+ . expect ( "completed submissions have an object-store prefix" ) ;
508+ let result = object_store_client
509+ . retrieve_chunk ( & prefix, index. into ( ) , ChunkType :: Output )
510+ . await
511+ . map_err ( StreamingChunkError :: Retrieval ) ;
512+ return Some ( (
513+ result,
514+ (
515+ client,
516+ object_store_client,
517+ submission_id,
518+ index + u63:: new ( 1 ) ,
519+ Some ( prefix) ,
520+ interval,
521+ ) ,
522+ ) ) ;
523+ }
524+ return None ;
525+ }
526+ submission:: SubmissionStatus :: Failed ( submission, chunk) => {
527+ let failure =
528+ crate :: common:: ChunkFailed :: from_internal ( chunk, & submission) ;
529+ return Some ( (
530+ Err ( StreamingChunkError :: Failed (
531+ crate :: errors:: SubmissionFailed ( submission. into ( ) , failure) ,
532+ ) ) ,
533+ (
534+ client,
535+ object_store_client,
536+ submission_id,
537+ index,
538+ prefix,
539+ interval,
540+ ) ,
541+ ) ) ;
542+ }
543+ submission:: SubmissionStatus :: Cancelled ( _) => {
544+ return Some ( (
545+ Err ( StreamingChunkError :: Cancelled ) ,
546+ (
547+ client,
548+ object_store_client,
549+ submission_id,
550+ index,
551+ prefix,
552+ interval,
553+ ) ,
554+ ) ) ;
555+ }
556+ }
557+
558+ tokio:: time:: sleep ( interval) . await ;
559+ if interval < SUBMISSION_POLLING_INTERVAL {
560+ interval = ( interval * 2 ) . min ( SUBMISSION_POLLING_INTERVAL ) ;
561+ }
562+ }
563+ } ,
564+ )
565+ . map ( |item| item. map_err ( CError ) )
566+ . boxed ( ) ;
567+ PyChunksIter :: from_stream ( self , stream)
568+ }
569+
430570 /// Blocks (and short-polls) until the submission is completed.
431571 ///
432572 /// We start with a small short-polling interval
@@ -457,6 +597,30 @@ impl ProducerClient {
457597 } )
458598 }
459599
600+ /// Return an awaitable that resolves immediately to an async iterator of output chunks.
601+ ///
602+ /// The iterator polls submission progress and yields each output chunk as soon as it is ready.
603+ ///
604+ /// # Errors
605+ ///
606+ /// Returns a Python error if creating the awaitable fails.
607+ pub fn async_stream_submission_chunks < ' p > (
608+ & self ,
609+ py : Python < ' p > ,
610+ submission_id : SubmissionId ,
611+ ) -> PyResult < Bound < ' p , PyAny > > {
612+ let me = self . clone ( ) ;
613+ let _tokio_active_runtime_guard = me. runtime . enter ( ) ;
614+ async_util:: future_into_py (
615+ py,
616+ async_util:: async_detach ( Box :: pin ( async move {
617+ Ok ( PyChunksAsyncIter :: from (
618+ me. streaming_submission_chunks ( submission_id) ,
619+ ) )
620+ } ) ) ,
621+ )
622+ }
623+
460624 /// Return an awaitable that resolves to an async iterator of output chunks.
461625 ///
462626 /// # Errors
@@ -565,7 +729,41 @@ impl ProducerClient {
565729 }
566730}
567731
568- pub type ChunksStream = BoxStream < ' static , CPyResult < Vec < u8 > , ChunkRetrievalError > > ;
732+ #[ derive( Debug ) ]
733+ enum StreamingChunkError {
734+ Retrieval ( ChunkRetrievalError ) ,
735+ Internal ( InternalProducerClientError ) ,
736+ Failed ( crate :: errors:: SubmissionFailed ) ,
737+ SubmissionNotFound ,
738+ Cancelled ,
739+ }
740+
741+ impl std:: fmt:: Display for StreamingChunkError {
742+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
743+ match self {
744+ Self :: Retrieval ( error) => error. fmt ( f) ,
745+ Self :: Internal ( error) => error. fmt ( f) ,
746+ Self :: Failed ( _) => write ! ( f, "Submission failed" ) ,
747+ Self :: SubmissionNotFound => write ! ( f, "Submission not found" ) ,
748+ Self :: Cancelled => write ! ( f, "Submission cancelled" ) ,
749+ }
750+ }
751+ }
752+
753+ impl std:: error:: Error for StreamingChunkError { }
754+
755+ impl From < CError < StreamingChunkError > > for PyErr {
756+ fn from ( value : CError < StreamingChunkError > ) -> Self {
757+ match value. 0 {
758+ StreamingChunkError :: Retrieval ( error) => CError ( error) . into ( ) ,
759+ StreamingChunkError :: Internal ( error) => CError ( error) . into ( ) ,
760+ StreamingChunkError :: Failed ( error) => CError ( error) . into ( ) ,
761+ error => PyException :: new_err ( error. to_string ( ) ) ,
762+ }
763+ }
764+ }
765+
766+ type ChunksStream = BoxStream < ' static , CPyResult < Vec < u8 > , StreamingChunkError > > ;
569767
570768#[ pyclass( module = "opsqueue" ) ]
571769pub struct PyChunksIter {
@@ -574,16 +772,20 @@ pub struct PyChunksIter {
574772}
575773
576774impl PyChunksIter {
775+ fn from_stream ( client : & ProducerClient , stream : ChunksStream ) -> Self {
776+ Self {
777+ stream : Arc :: new ( tokio:: sync:: Mutex :: new ( stream) ) ,
778+ runtime : client. runtime . clone ( ) ,
779+ }
780+ }
781+
577782 pub ( crate ) fn new ( client : & ProducerClient , prefix : String , chunks_total : u63 ) -> Self {
578783 let stream = client
579784 . object_store_client
580785 . retrieve_chunks ( prefix, chunks_total, ChunkType :: Output )
581- . map_err ( CError )
786+ . map_err ( |error| CError ( StreamingChunkError :: Retrieval ( error ) ) )
582787 . boxed ( ) ;
583- Self {
584- stream : Arc :: new ( tokio:: sync:: Mutex :: new ( stream) ) ,
585- runtime : client. runtime . clone ( ) ,
586- }
788+ Self :: from_stream ( client, stream)
587789 }
588790}
589791
@@ -593,7 +795,7 @@ impl PyChunksIter {
593795 slf
594796 }
595797
596- fn __next__ ( & self , py : Python < ' _ > ) -> Option < CPyResult < Vec < u8 > , ChunkRetrievalError > > {
798+ fn __next__ ( & self , py : Python < ' _ > ) -> Option < CPyResult < Vec < u8 > , StreamingChunkError > > {
597799 // The only time we need the GIL is when turning the result back.
598800 // By unlocking here, we reduce the chance of deadlocks.
599801 py. detach ( move || {
0 commit comments