@@ -732,3 +732,201 @@ def test_concurrent_workers_stress(test_db_path: Path, iteration: int) -> None:
732732 f"Iteration { iteration } failed.\n stdout: { result .stdout } \n stderr: { result .stderr } "
733733 )
734734 assert_no_determinism_errors (result )
735+
736+
737+ # =============================================================================
738+ # Test 7: Streaming stress test - many write_event_to_stream and send_event calls
739+ # This tests determinism when a step fires many background tasks for streaming
740+ # and internal event sending, then gets interrupted and replayed.
741+ # =============================================================================
742+
743+ STREAMING_STRESS_WORKFLOW_CODE = textwrap .dedent ("""
744+ import asyncio
745+
746+ class ProgressEvent(Event):
747+ progress: int = Field(default=0)
748+
749+ class WorkItem(Event):
750+ item_id: int = Field(default=0)
751+
752+ class WorkDone(Event):
753+ item_id: int = Field(default=0)
754+ total_processed: int = Field(default=0)
755+
756+ class FanOutComplete(Event):
757+ pass
758+
759+ class StreamingStressWorkflow(Workflow):
760+ @step
761+ async def fan_out(self, ctx: Context, ev: StartEvent) -> FanOutComplete:
762+ # Fire many stream writes and internal events concurrently
763+ # This creates many background tasks that call DBOS operations
764+ for i in range(15):
765+ ctx.write_event_to_stream(ProgressEvent(progress=i))
766+ ctx.send_event(WorkItem(item_id=i))
767+ print(f"STEP:fan_out:dispatched_15_items", flush=True)
768+ # Write completion signal to stream for interrupt tests
769+ ctx.write_event_to_stream(ProgressEvent(progress=999))
770+ return FanOutComplete()
771+
772+ @step(num_workers=4)
773+ async def process_work(self, ctx: Context, ev: WorkItem) -> WorkDone:
774+ # Each worker also writes to stream, creating more concurrent DBOS ops
775+ await asyncio.sleep(0.01) # Small delay to increase interleaving
776+ ctx.write_event_to_stream(ProgressEvent(progress=100 + ev.item_id))
777+ print(f"STEP:process_work:{ev.item_id}:complete", flush=True)
778+ return WorkDone(item_id=ev.item_id)
779+
780+ @step
781+ async def after_fanout(self, ctx: Context, ev: FanOutComplete) -> None:
782+ # Consume FanOutComplete, don't trigger anything
783+ print("STEP:after_fanout:complete", flush=True)
784+ return None
785+
786+ @step
787+ async def collect(self, ctx: Context, ev: WorkDone) -> StopEvent:
788+ # First WorkDone ends the workflow
789+ print(f"STEP:collect:{ev.item_id}:complete", flush=True)
790+ return StopEvent(result={"first_done": ev.item_id})
791+ """ )
792+
793+
794+ def test_streaming_stress_determinism (test_db_path : Path ) -> None :
795+ """Test determinism with many concurrent stream writes and send_event calls."""
796+ run_id = "test-streaming-stress-001"
797+ db_url = f"sqlite+pysqlite:///{ test_db_path } ?check_same_thread=false"
798+
799+ run_main = textwrap .dedent (f'''
800+ wf = StreamingStressWorkflow(runtime=runtime)
801+ runtime.launch()
802+ try:
803+ ctx = Context(wf)
804+ handler = ctx._workflow_run(wf, StartEvent(), run_id="{ run_id } ")
805+ result = await handler
806+ print(f"RESULT:{{result}}", flush=True)
807+ print("SUCCESS", flush=True)
808+ except Exception as e:
809+ print(f"ERROR:{{type(e).__name__}}:{{e}}", flush=True)
810+ raise
811+ finally:
812+ runtime.destroy()
813+ ''' )
814+
815+ print ("\n === Running streaming stress workflow ===" )
816+ result = run_workflow_script (
817+ make_script (
818+ STREAMING_STRESS_WORKFLOW_CODE , run_main , db_url , "test-streaming-stress"
819+ )
820+ )
821+ print (f"stdout: { result .stdout } " )
822+ print (f"stderr: { result .stderr } " )
823+
824+ assert "SUCCESS" in result .stdout , (
825+ f"Should complete successfully.\n stdout: { result .stdout } \n stderr: { result .stderr } "
826+ )
827+ assert_no_determinism_errors (result )
828+
829+
830+ def test_streaming_interrupt_resume (test_db_path : Path ) -> None :
831+ """Test interrupt/resume with many concurrent stream writes in flight."""
832+ run_id = "test-streaming-interrupt-001"
833+ db_url = f"sqlite+pysqlite:///{ test_db_path } ?check_same_thread=false"
834+
835+ # Start workflow and interrupt after seeing the ProgressEvent with progress=999
836+ # (the one returned by fan_out). This ensures all the send_event and
837+ # write_event_to_stream calls have been made but workers are still running.
838+ start_main = textwrap .dedent (f'''
839+ wf = StreamingStressWorkflow(runtime=runtime)
840+ runtime.launch()
841+ ctx = Context(wf)
842+ handler = ctx._workflow_run(wf, StartEvent(), run_id="{ run_id } ")
843+ async for event in handler.stream_events():
844+ print(f"EVENT:{{type(event).__name__}}:{{getattr(event, 'progress', getattr(event, 'item_id', ''))}}", flush=True)
845+ # Interrupt after fan_out completes (it emits progress=999)
846+ if isinstance(event, ProgressEvent) and event.progress == 999:
847+ print("INTERRUPTING_AFTER_FANOUT", flush=True)
848+ import os
849+ os._exit(0)
850+ ''' )
851+
852+ # Resume and complete
853+ resume_main = textwrap .dedent (f'''
854+ wf = StreamingStressWorkflow(runtime=runtime)
855+ runtime.launch()
856+ try:
857+ ctx = Context(wf)
858+ handler = ctx._workflow_run(wf, StartEvent(), run_id="{ run_id } ")
859+ result = await handler
860+ print(f"RESULT:{{result}}", flush=True)
861+ print("SUCCESS", flush=True)
862+ except Exception as e:
863+ print(f"ERROR:{{type(e).__name__}}:{{e}}", flush=True)
864+ raise
865+ finally:
866+ runtime.destroy()
867+ ''' )
868+
869+ print ("\n === Starting streaming workflow (will interrupt after fan_out) ===" )
870+ result1 = run_workflow_script (
871+ make_script (
872+ STREAMING_STRESS_WORKFLOW_CODE , start_main , db_url , "test-streaming-int"
873+ )
874+ )
875+ print (f"stdout: { result1 .stdout } " )
876+ print (f"stderr: { result1 .stderr } " )
877+
878+ assert "STEP:fan_out:dispatched_15_items" in result1 .stdout , (
879+ "Fan out should complete"
880+ )
881+ assert "INTERRUPTING_AFTER_FANOUT" in result1 .stdout , "Should have interrupted"
882+
883+ print ("\n === Resuming streaming workflow ===" )
884+ result2 = run_workflow_script (
885+ make_script (
886+ STREAMING_STRESS_WORKFLOW_CODE , resume_main , db_url , "test-streaming-int"
887+ )
888+ )
889+ print (f"stdout: { result2 .stdout } " )
890+ print (f"stderr: { result2 .stderr } " )
891+
892+ assert_no_determinism_errors (result2 )
893+ assert "SUCCESS" in result2 .stdout or result2 .returncode == 0 , (
894+ f"Resume should succeed.\n stdout: { result2 .stdout } \n stderr: { result2 .stderr } "
895+ )
896+
897+
898+ @pytest .mark .parametrize ("iteration" , range (5 ))
899+ def test_streaming_stress_repeated (test_db_path : Path , iteration : int ) -> None :
900+ """Stress test streaming - run 5 times to catch timing issues."""
901+ run_id = f"test-streaming-repeated-{ iteration } "
902+ db_url = f"sqlite+pysqlite:///{ test_db_path } ?check_same_thread=false"
903+
904+ run_main = textwrap .dedent (f'''
905+ wf = StreamingStressWorkflow(runtime=runtime)
906+ runtime.launch()
907+ try:
908+ ctx = Context(wf)
909+ handler = ctx._workflow_run(wf, StartEvent(), run_id="{ run_id } ")
910+ result = await handler
911+ print(f"RESULT:{{result}}", flush=True)
912+ print("SUCCESS", flush=True)
913+ except Exception as e:
914+ print(f"ERROR:{{type(e).__name__}}:{{e}}", flush=True)
915+ raise
916+ finally:
917+ runtime.destroy()
918+ ''' )
919+
920+ result = run_workflow_script (
921+ make_script (
922+ STREAMING_STRESS_WORKFLOW_CODE ,
923+ run_main ,
924+ db_url ,
925+ f"streaming-stress-{ iteration } " ,
926+ )
927+ )
928+
929+ assert "SUCCESS" in result .stdout , (
930+ f"Iteration { iteration } failed.\n stdout: { result .stdout } \n stderr: { result .stderr } "
931+ )
932+ assert_no_determinism_errors (result )
0 commit comments