Write a connector of your own when a model must read changes from, or write records to, a system that desflow has no connector for. It builds on Ingress and Egress, which describe the connectors desflow has and the threads they run on. This page writes an egress that writes each event as a row of a CSV file, and an ingress that follows a JSON Lines file of changes. Neither needs a service.
Write an Egress
An egress subclasses BaseEgress and has one method, async run(queue). desflow runs it on the egress thread's asyncio loop, apart from the simulation. The queue holds batches: each batch is a list of records, in the record shape every sink receives. run takes a batch with get_nowait, and sleeps when the queue is empty so the other connectors on the loop can run:
classCsvEgress(BaseEgress):"""Writes one CSV row per event: its time, key and task, then the payload fields that `columns` names."""def__init__(self,path:str,columns:List[str]):self.path=Path(path)self.columns=list(columns)self.active_tasks=0# teardown waits while a write is in progressasyncdefrun(self,egress_queue:queue.Queue)->None:withself.path.open("w",newline="")asfile:writer=csv.writer(file)writer.writerow(["sim_ts","key","task",*self.columns])whileTrue:try:batch=egress_queue.get_nowait()exceptqueue.Empty:awaitasyncio.sleep(0.1)# let the other connectors runcontinueself.active_tasks+=1try:# A batch also holds telemetry; this sink writes only the eventsrows=[self._row(record)forrecordinbatchifrecord["stream_type"]=="event"]# A file write blocks, so it runs on a worker threadawaitasyncio.to_thread(self._write,file,writer,rows)finally:self.active_tasks-=1def_row(self,record:dict)->list:value=record["value"]ifisinstance(record.get("value"),dict)else{}fields=[value.get(column)forcolumninself.columns]return[record["sim_ts"],record.get("key"),record.get("task"),*fields]@staticmethoddef_write(file,writer,rows:list)->None:writer.writerows(rows)file.flush()
Three rules apply to every egress:
Do not block the loop. A write that blocks, such as a file write or a client without async, runs on a worker thread with asyncio.to_thread.
Count the writes in progress. At the end of the run, teardown waits for every queue to empty, then waits while the sink's active_tasks is above 0. A batch taken from the queue but not yet written is therefore not lost.
Copy before you change a record. Every sink receives the same dictionaries, so a sink that changes one in place changes what the others write. This sink builds new rows.
An error that ends run is logged at ERROR with the connector's name. The sink then takes no more batches, so once its queue is full the simulation waits, and after drain_stall_seconds the run raises RuntimeError, as Egress describes.
Write an Ingress
An ingress subclasses BaseIngress and has one method too, async run(queue), which desflow runs on the ingress thread. It puts each change on the queue as a (path, value) pair. parse_change turns a message such as {"path": "Shop.resources.packers.current_cap", "value": 2} into that pair, with the check the built-in connectors use. The ingress monitor applies what is queued every 0.1 simulation seconds, and the registry checks each value before it stores it (Live Parameters):
classJsonlIngress(BaseIngress):"""Follows a JSON Lines file and applies each line it adds, such as `{"path": "Shop.resources.packers.current_cap", "value": 2}`."""def__init__(self,path:str,poll:float=0.1):self.path=Path(path)self.poll=pollasyncdefrun(self,ingress_queue:queue.Queue)->None:read=0# the lines already appliedwhileTrue:ifself.path.exists():# A file read blocks, so it runs on a worker threadtext=awaitasyncio.to_thread(self.path.read_text)# Only whole lines: a writer may be partway through the last onelines=text[:text.rfind("\n")+1].splitlines()forlineinlines[read:]:try:ingress_queue.put(parse_change(json.loads(line)))exceptValueErroraserror:# also a line that is not JSONlogger.warning("Skipped a line of %s: %s",self.path,error)read=len(lines)awaitasyncio.sleep(self.poll)
A line of the file that is not a change is logged as one WARNING and skipped, as the built-in connectors do, so one bad message does not stop the run. At the end of the run desflow cancels run, so it needs no way to stop.
Use Them
A connector of your own goes where a built-in one goes. Each config key is an argument of the class's constructor:
The classes are in write_a_connector_logic.py, beside the blueprint. type: !python names the class, and the loader builds it with config as its arguments.
env=DynamicRealtimeEnvironment(factor=0.05)env.registry.register_sim_parameter(SimParameter(sim_id="Shop",service={"packing":DistributionConfig(dist="constant",value=1.5)},resources={"packers":CapacityConfig(current_cap=1,max_cap=3)},))env.setup_ingress([JsonlIngress("changes.jsonl")])env.setup_egress([CsvEgress("events.csv",columns=["status","event_type"])])packers=DynamicResource(env,"Shop","packers")deforder(env,number):key,path_id=f"orders-{number}","Shop.service.packing"env.publish_event(key,{"path_id":path_id,"status":"queued"},"pack_order",number-1)withpackers.request()asrequest:yieldrequestenv.publish_event(key,{"path_id":path_id,"status":"started"},"pack_order",number-1)yieldenv.timeout(1.5)env.publish_event(key,{"event_type":"order_packed"},"pack_order",number-1)defarrivals(env):number=0whileTrue:yieldenv.timeout(1.0)number+=1env.process(order(env,number))env.process(arrivals(env))try:env.run(until=20)finally:env.teardown()# writes what is still buffered and stops the threads
The low-level API attaches the same objects with setup_ingress and setup_egress, and teardown waits for the CSV sink to write what is still queued.
The shop is paced at factor: 0.05, so 20 simulation seconds pass in each real second, and the run lasts one second of real time. An ingress reads its source while time passes, and a run at factor: 0 can end before the source is read once.
Check the Run
Before the run, changes.jsonl holds one line, which sets the packers to 2 units. An order arrives every second from 1 second, and each takes 1.5 seconds, so one packer would fall behind. The ingress applies the change in the first moments of the run, and from then on each order starts when it arrives. The first rows of events.csv:
orders-2 starts at 2 seconds while orders-1 is still being packed, so the second unit is there. By 20 seconds, 19 orders have arrived and started, and the 18 that arrived by 18 seconds are packed. That is 19 queued, 19 started and 18 order_packed events: 56 rows. All three forms write the same rows.
Example
The example has the Python file with the two connectors, the YAML blueprint, and a script. The script writes changes.jsonl, runs the YAML, builder and low-level forms, and prints the packers' units at the end and the number of rows in events.csv for each. Put the three files in one folder and run python write_a_connector.py from it.
"""Python for write_a_connector.yaml: an egress that writes events to a CSV file andan ingress that follows a JSON Lines file of changes, as the Write a Connector guidebuilds them."""importasyncioimportcsvimportjsonimportloggingimportqueuefrompathlibimportPathfromtypingimportListfromdesflow.connectors.egress.baseimportBaseEgressfromdesflow.connectors.ingress.baseimportBaseIngress,parse_changelogger=logging.getLogger(__name__)classCsvEgress(BaseEgress):"""Writes one CSV row per event: its time, key and task, then the payload fields that `columns` names."""def__init__(self,path:str,columns:List[str]):self.path=Path(path)self.columns=list(columns)self.active_tasks=0# teardown waits while a write is in progressasyncdefrun(self,egress_queue:queue.Queue)->None:withself.path.open("w",newline="")asfile:writer=csv.writer(file)writer.writerow(["sim_ts","key","task",*self.columns])whileTrue:try:batch=egress_queue.get_nowait()exceptqueue.Empty:awaitasyncio.sleep(0.1)# let the other connectors runcontinueself.active_tasks+=1try:# A batch also holds telemetry; this sink writes only the eventsrows=[self._row(record)forrecordinbatchifrecord["stream_type"]=="event"]# A file write blocks, so it runs on a worker threadawaitasyncio.to_thread(self._write,file,writer,rows)finally:self.active_tasks-=1def_row(self,record:dict)->list:value=record["value"]ifisinstance(record.get("value"),dict)else{}fields=[value.get(column)forcolumninself.columns]return[record["sim_ts"],record.get("key"),record.get("task"),*fields]@staticmethoddef_write(file,writer,rows:list)->None:writer.writerows(rows)file.flush()classJsonlIngress(BaseIngress):"""Follows a JSON Lines file and applies each line it adds, such as `{"path": "Shop.resources.packers.current_cap", "value": 2}`."""def__init__(self,path:str,poll:float=0.1):self.path=Path(path)self.poll=pollasyncdefrun(self,ingress_queue:queue.Queue)->None:read=0# the lines already appliedwhileTrue:ifself.path.exists():# A file read blocks, so it runs on a worker threadtext=awaitasyncio.to_thread(self.path.read_text)# Only whole lines: a writer may be partway through the last onelines=text[:text.rfind("\n")+1].splitlines()forlineinlines[read:]:try:ingress_queue.put(parse_change(json.loads(line)))exceptValueErroraserror:# also a line that is not JSONlogger.warning("Skipped a line of %s: %s",self.path,error)read=len(lines)awaitasyncio.sleep(self.poll)
# The Write a Connector guide: a shop whose packers follow a file of changes and# whose events go to a CSV file, through two connectors of its own.# Run it with: python write_a_connector.py, which writes changes.jsonl first.# In a folder with no changes.jsonl, desflow run write_a_connector.yaml keeps one packer.simulation:sim_id:Shopfactor:0.05# 20 simulation seconds a second, so the file is read during the runrandom_seed:42resources:packers:{current_cap:1, max_cap:3}services:packing:{dist:constant, value:1.5 s}arrivals:orders:dist:constantvalue:1 sspawn:pack_ordertasks:pack_order:service:packingresource:packerspayload:{event_type:order_packed}ingress:-type:!pythonwrite_a_connector_logic.JsonlIngressconfig:{path:changes.jsonl}egress:-type:!pythonwrite_a_connector_logic.CsvEgressconfig:{path:events.csv, columns:[status,event_type]}run:until:20 s
"""Write a Connector: an egress that writes events to a CSV file and an ingress thatfollows a JSON Lines file of changes.An order arrives every second and each takes 1.5 seconds to pack, so one packerfalls behind. Before the run, changes.jsonl holds one change that sets the packers to2. The ingress applies it at the start, and the events go to events.csv. Eachfunction runs the shop in the current folder, in YAML, in the builder or in thelow-level API, and returns the packers' capacity at the end and the rows ofevents.csv."""importcsvimportjsonimportloggingimportsysfrompathlibimportPathfromdesflowimport(CapacityConfig,DistributionConfig,DynamicRealtimeEnvironment,DynamicResource,SimParameter,SimulationContext,)HERE=Path(__file__).resolve().parentsys.path.insert(0,str(HERE))# write_a_connector_logic.py is beside the blueprintfromwrite_a_connector_logicimportCsvEgress,JsonlIngress# noqa: E402logging.basicConfig(level=logging.WARNING,format="%(levelname)s [%(asctime)s] %(message)s")CHANGE={"path":"Shop.resources.packers.current_cap","value":2}def_rows():withopen("events.csv",newline="")asfile:returnlist(csv.DictReader(file))defyaml_form():"""The blueprint beside the classes."""Path("changes.jsonl").write_text(json.dumps(CHANGE)+"\n")app=SimulationContext.from_yaml(HERE/"write_a_connector.yaml")app.run()returnapp.get_resource("packers").capacity,_rows()defbuilder_form():"""The same shop with the builder."""Path("changes.jsonl").write_text(json.dumps(CHANGE)+"\n")app=(SimulationContext(sim_id="Shop",factor=0.05,random_seed=42).add_resource("packers",current_cap=1,max_cap=3).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="constant",value=1.0).add_ingress(JsonlIngress("changes.jsonl")).add_egress(CsvEgress("events.csv",columns=["status","event_type"])))@app.task(service_id="packing",resource_id="packers")defpack_order(task_id:int):return{"event_type":"order_packed"}@app.arrival_loop("orders")deforder_generator(context:SimulationContext):whileTrue:order=yieldcontext.wait_for_arrival("orders")context.spawn(pack_order(context.next_instance("pack_order"),entity=order))app.run(until=20)returnapp.get_resource("packers").capacity,_rows()deflow_level_form():"""The same shop with the environment and the resource."""Path("changes.jsonl").write_text(json.dumps(CHANGE)+"\n")env=DynamicRealtimeEnvironment(factor=0.05)env.registry.register_sim_parameter(SimParameter(sim_id="Shop",service={"packing":DistributionConfig(dist="constant",value=1.5)},resources={"packers":CapacityConfig(current_cap=1,max_cap=3)},))env.setup_ingress([JsonlIngress("changes.jsonl")])env.setup_egress([CsvEgress("events.csv",columns=["status","event_type"])])packers=DynamicResource(env,"Shop","packers")deforder(env,number):key,path_id=f"orders-{number}","Shop.service.packing"env.publish_event(key,{"path_id":path_id,"status":"queued"},"pack_order",number-1)withpackers.request()asrequest:yieldrequestenv.publish_event(key,{"path_id":path_id,"status":"started"},"pack_order",number-1)yieldenv.timeout(1.5)env.publish_event(key,{"event_type":"order_packed"},"pack_order",number-1)defarrivals(env):number=0whileTrue:yieldenv.timeout(1.0)number+=1env.process(order(env,number))env.process(arrivals(env))try:env.run(until=20)finally:env.teardown()# writes what is still buffered and stops the threadsreturnpackers.capacity,_rows()if__name__=="__main__":forformin(yaml_form,builder_form,low_level_form):capacity,rows=form()print(f"{form.__name__:15} packers: {capacity}, rows in events.csv: {len(rows)}")