Write the ids of one kind of record to one sink and use them in another: customers go to one JSON Lines file, and the orders that name those customers go to a second file, so the two files join on customer_id.
Before You Start
The commands run from the root of a clone of the repository, where the files are in docs/snippets/generate-data/. The Full Files block of this page holds the same files, for a reader who saves them by hand.
Five customers are written at the start and another about every 10 seconds. An order arrives about once a second, and lookup gives it the customer_id of a customer already written.
customer_payload=Template({"customer_id":{"gen":"sequence","start":1},"country":{"gen":"one_of","choices":["AU","NZ","SG"]},},name="tasks.customer",)order_payload=Template({"order_id":{"gen":"sequence","start":1},"customer_id":{"gen":"lookup","task":"customer","path":"customer_id"},"total":{"gen":"uniform","low":5,"high":200,"decimals":2},},name="tasks.order",)# A task that something looks up keeps its rows. `history` is the blueprint's# default, and a builder task needs it because a `Template` payload is not searched# for lookups.@app.task(name="customer",history=1_000_000)defcustomer(task_id):"""Gives one customer record."""returncustomer_payload@app.task(name="order")deforder(task_id):"""Gives one order record."""returnorder_payload
A task that something looks up keeps its rows, and history= says how many. A Builder task needs it, because a Template payload is not searched for lookups. A blueprint keeps 1,000,000 rows by default.
sampler=Sampler(seed=42)country=sampler.generator("tasks.customer.payload.country")pick=sampler.generator("tasks.order.payload.customer_id")total=sampler.generator("tasks.order.payload.total")customer_gap=DistributionConfig(dist="exponential",rate=0.1)# The random sequence the blueprint's draw usescustomer_gap.stream="arrival.customers"order_gap=DistributionConfig(dist="exponential",rate=1)order_gap.stream="arrival.orders"customer_rows:list=[]# the customers written so far, for a lookupsent={"customer":0,"order":0}
Sampler(seed=42) supplies every draw: a random generator for each payload field and for the gap. The stream line names the random sequence the blueprint uses for this arrival, so the three forms draw the same gaps.
defwrite_customer(key):"""Publishes the next customer and keeps its row."""row={"customer_id":len(customer_rows)+1,"country":COUNTRIES[int(country.integers(len(COUNTRIES)))],}customer_rows.append(row)env.publish_event(key,row,task="customer",instance=sent["customer"])sent["customer"]+=1defcustomers():fornumberinitertools.count(6):# 1 to 5 exist at the startyieldenv.timeout(sampler.sample(customer_gap))write_customer(f"customers-{number}")deforders():fornumberinitertools.count(1):yieldenv.timeout(sampler.sample(order_gap))# The lookup: one row of the customers written so far, chosen at randomcustomer=customer_rows[int(pick.integers(len(customer_rows)))]payload={"order_id":number,"customer_id":customer["customer_id"],"total":round(float(total.uniform(5,200)),2),}env.publish_event(f"orders-{number}",payload,task="order",instance=sent["order"])sent["order"]+=1deffirst(number):yieldenv.timeout(0)write_customer(f"customers-{number}")env.process(customers())env.process(orders())fornumberinrange(1,6):env.process(first(number))
A Low-level script keeps the customers in a list and picks one at random for each order.
Send Each Kind to Its File
A when on each sink lets through the records of one task. The first file takes the records of the task customer and the second the records of the task order.
out holds files named customers_<8 hex digits>.jsonl and orders_<8 hex digits>.jsonl, one flat row for each record. To see the records without writing files, print the first 8 of them:
The docs test runs all three forms into real files in a temporary folder. Each file holds the records of one task, every order in the orders file names a customer in the customers file, and the three forms write the same files.
# Join across sinks: customers go to one file, and the orders that name them to another.## Customers are written to customers.jsonl. Each order takes its customer id from a# customer already written, with `lookup`, and goes to orders.jsonl. A `when` on each# sink sends it the records of one task, so the two files join on customer_id.## Needs: the parquet extra, which holds the file writers. DEST_PATH sets the folder# (data by default).# Run it with: desflow run docs/snippets/generate-data/join_across_sinks.yamlsimulation:sim_id:Shopfactor:0random_seed:42logical_start_time:2026-01-01T00:00:00egress:-type:Jsonlwhen:{expr:"record.task=='customer'"}config:default_path:${DEST_PATH:-data}/customers.jsonl-type:Jsonlwhen:{expr:"record.task=='order'"}config:default_path:${DEST_PATH:-data}/orders.jsonlarrivals:customers:{initial:5, dist:exponential, rate:0.1, spawn:customer}orders:{dist:exponential, rate:1, spawn:order}tasks:customer:payload:customer_id:{gen:sequence, start:1}country:{gen:one_of, choices:[AU,NZ,SG]}order:payload:order_id:{gen:sequence, start:1}customer_id:{gen:lookup, task:customer, path:customer_id}total:{gen:uniform, low:5, high:200, decimals:2}run:{until:60}
"""Join across sinks: customers to one file, and the orders that name them to another.The builder version of `join_across_sinks.yaml`: 5 customers at the start and one moreabout every 10 seconds, and an order about every second whose `lookup` takes thecustomer id from a customer already written. Each sink takes the records of one task,with `when`.Needs: the parquet extra, which holds the file writers. DEST_PATH sets the folder (databy default).Run it with: python docs/snippets/generate-data/join_across_sinks_builder.py"""importloggingimportosfromdatetimeimportdatetime,timezonefromdesflowimportJsonlStorageEgress,SimulationContextfromdesflow.valuesimportTemplateUNTIL=60defbuild()->SimulationContext:"""Builds the shop, with a file for each kind of record."""folder=os.environ.get("DEST_PATH","data")app=SimulationContext(sim_id="Shop",factor=0,random_seed=42,logical_start_time=datetime(2026,1,1,tzinfo=timezone.utc),)app.add_egress(JsonlStorageEgress(default_path=f"{folder}/customers.jsonl"),when=lambdarecord:record.get("task")=="customer",)app.add_egress(JsonlStorageEgress(default_path=f"{folder}/orders.jsonl"),when=lambdarecord:record.get("task")=="order",)app.add_arrival("customers","exponential",rate=0.1,spawn="customer",initial=5)app.add_arrival("orders","exponential",rate=1,spawn="order")customer_payload=Template({"customer_id":{"gen":"sequence","start":1},"country":{"gen":"one_of","choices":["AU","NZ","SG"]},},name="tasks.customer",)order_payload=Template({"order_id":{"gen":"sequence","start":1},"customer_id":{"gen":"lookup","task":"customer","path":"customer_id"},"total":{"gen":"uniform","low":5,"high":200,"decimals":2},},name="tasks.order",)# A task that something looks up keeps its rows. `history` is the blueprint's# default, and a builder task needs it because a `Template` payload is not searched# for lookups.@app.task(name="customer",history=1_000_000)defcustomer(task_id):"""Gives one customer record."""returncustomer_payload@app.task(name="order")deforder(task_id):"""Gives one order record."""returnorder_payloadreturnappif__name__=="__main__":logging.basicConfig(level=logging.INFO,format="%(levelname)s%(message)s")build().run(until=UNTIL)
"""Join across sinks: customers to one file, and the orders that name them to another.The low-level version of `join_across_sinks.yaml`: plain SimPy on a`DynamicRealtimeEnvironment`, which publishes each record with `publish_event`. Eachdraw is made with `Sampler`, from the random sequence the blueprint's generator at thatplace uses, so the draws are the blueprint's. The script keeps each customer row in alist, so that an order's lookup is one draw of a row of that list, and each sink takesthe records of one task through a predicate.Needs: the parquet extra, which holds the file writers. DEST_PATH sets the folder (databy default).Run it with: python docs/snippets/generate-data/join_across_sinks_low_level.py"""importitertoolsimportloggingimportosfromdatetimeimportdatetime,timezonefromdesflowimport(DistributionConfig,DynamicRealtimeEnvironment,JsonlStorageEgress,Sampler,)UNTIL=60START=datetime(2026,1,1,tzinfo=timezone.utc)ENVIRONMENT:dict={"logical_start_time":START,"timezone":"UTC"}COUNTRIES=["AU","NZ","SG"]defbuild(env:DynamicRealtimeEnvironment)->None:"""Starts the customers and the orders on `env`."""sampler=Sampler(seed=42)country=sampler.generator("tasks.customer.payload.country")pick=sampler.generator("tasks.order.payload.customer_id")total=sampler.generator("tasks.order.payload.total")customer_gap=DistributionConfig(dist="exponential",rate=0.1)# The random sequence the blueprint's draw usescustomer_gap.stream="arrival.customers"order_gap=DistributionConfig(dist="exponential",rate=1)order_gap.stream="arrival.orders"customer_rows:list=[]# the customers written so far, for a lookupsent={"customer":0,"order":0}defwrite_customer(key):"""Publishes the next customer and keeps its row."""row={"customer_id":len(customer_rows)+1,"country":COUNTRIES[int(country.integers(len(COUNTRIES)))],}customer_rows.append(row)env.publish_event(key,row,task="customer",instance=sent["customer"])sent["customer"]+=1defcustomers():fornumberinitertools.count(6):# 1 to 5 exist at the startyieldenv.timeout(sampler.sample(customer_gap))write_customer(f"customers-{number}")deforders():fornumberinitertools.count(1):yieldenv.timeout(sampler.sample(order_gap))# The lookup: one row of the customers written so far, chosen at randomcustomer=customer_rows[int(pick.integers(len(customer_rows)))]payload={"order_id":number,"customer_id":customer["customer_id"],"total":round(float(total.uniform(5,200)),2),}env.publish_event(f"orders-{number}",payload,task="order",instance=sent["order"])sent["order"]+=1deffirst(number):yieldenv.timeout(0)write_customer(f"customers-{number}")env.process(customers())env.process(orders())fornumberinrange(1,6):env.process(first(number))defrun()->None:"""Runs the model unpaced, with a file for each kind of record."""env=DynamicRealtimeEnvironment(factor=0,**ENVIRONMENT)folder=os.environ.get("DEST_PATH","data")env.setup_egress([JsonlStorageEgress(default_path=f"{folder}/customers.jsonl"),JsonlStorageEgress(default_path=f"{folder}/orders.jsonl"),],predicates=[lambdarecord:record.get("task")=="customer",lambdarecord:record.get("task")=="order",],)build(env)try:env.run(until=UNTIL)finally:env.teardown()if__name__=="__main__":logging.basicConfig(level=logging.INFO,format="%(levelname)s%(message)s")run()