IcebergStorageEgress appends records to an Apache Iceberg table through a catalog, so any engine that reads Iceberg, such as Spark, Trino or DuckDB, can query them. This page appends an hour of a shop's orders to the table shop.events through a catalog you pick, then reads the table back.
Before You Start
Egress explains the sink, the columns of a table it creates, and upserts.
Install the extra: pip install "desflow[iceberg]". It covers every catalog on this page.
The SQLite catalog needs no service. The REST catalog needs odctl up catalog, and the PostgreSQL catalog odctl up postgres. See Installation for the one-time odctl install. Stop a profile with odctl down <profile> --volumes.
The Glue catalog needs AWS credentials and an S3 bucket for the table files.
Steps
Choose the Catalog
catalog is a mapping of PyIceberg catalog properties, which desflow passes to PyIceberg's load_catalog on the first write. type picks the kind of catalog, and the optional name key names it. The script keeps one mapping for each catalog, and ICEBERG_CATALOG picks one:
importosfrompathlibimportPath# The SQLite database and the local table files go in the folder iceberg/FOLDER=Path("iceberg").resolve()FOLDER.mkdir(exist_ok=True)# One mapping of PyIceberg catalog properties for each catalogCATALOGS={# A SQL catalog on SQLite, with no service, for a first run"sqlite":{"type":"sql","uri":f"sqlite:///{FOLDER}/catalog.db","warehouse":f"file://{FOLDER}/warehouse",},# The REST catalog of `odctl up catalog`, with the table files on its SeaweedFS"rest":{"type":"rest","uri":"http://127.0.0.1:8181","warehouse":"s3://warehouse/","s3.endpoint":"http://127.0.0.1:8333","s3.access-key-id":"user","s3.secret-access-key":"password","s3.region":"us-east-1",},# A SQL catalog on the PostgreSQL of `odctl up postgres`, with local table files"postgres":{"type":"sql","uri":"postgresql+psycopg2://user:password@127.0.0.1:5432/odctl","warehouse":f"file://{FOLDER}/warehouse",},# The AWS Glue Data Catalog, with the table files in S3 and the standard AWS settings"glue":{"type":"glue","glue.region":"us-east-1","s3.region":"us-east-1","warehouse":os.getenv("ICEBERG_WAREHOUSE","s3://my-bucket/warehouse"),},}name=os.getenv("ICEBERG_CATALOG","sqlite")catalog={"name":name,**CATALOGS[name]}
The Glue mapping gives no keys, so PyIceberg uses the standard AWS settings: environment variables such as AWS_ACCESS_KEY_ID, the files in ~/.aws, or the role the machine runs as. ICEBERG_WAREHOUSE names the S3 folder of the table files. PyIceberg's configuration lists the properties of each catalog.
Write the Table
The sink creates the table from the first batch, with a column for each key of the records. Each flush is one commit, and every commit writes new metadata, so the sink gets a large batch of its own:
fromdesflowimportIcebergStorageEgressdeficeberg_sink(catalog:dict,table:str="shop.events")->IcebergStorageEgress:"""Every event as a row of `table`, which the sink creates from the first batch."""returnIcebergStorageEgress(catalog=catalog,default_table=table)sink=iceberg_sink(catalog)# One commit per flush, so the sink gets a large batch of its ownBATCH_SIZE=200_000
Each form runs the shop for an hour of simulated time at factor=0.0. The run flushes once, at its end, so the hour of orders is one commit:
egress:# Every event as a row of shop.events, which the sink creates from the first batch-type:Icebergconfig:default_table:shop.eventscatalog:!pythoniceberg_tables.catalog# One commit per flush, so the sink gets a large batch of its ownbatch_size:200000
The blueprint loads the mapping of iceberg_tables.py with !python, so ICEBERG_CATALOG picks the catalog in every form.
importloggingfromdesflowimportSimulationContextdefbuild_shop(sink:IcebergStorageEgress)->SimulationContext:"""The shop, appending to the table of the sink given, as fast as it can."""app=(SimulationContext(sim_id="Shop",factor=0.0,random_seed=42).add_resource("packers",current_cap=1,max_cap=1).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="exponential",rate=0.5).add_egress(sink,batch_size=BATCH_SIZE))@app.task(service_id="packing",resource_id="packers")defpack_order(task_id:int,entity):# The finished event is the order recordreturn{"order_id":task_id,"amount":19.99}@app.arrival_loop("orders")deforders(context:SimulationContext):whileTrue:order=yieldcontext.wait_for_arrival("orders")context.spawn(pack_order(context.next_instance("pack_order"),entity=order))returnappapp=build_shop(sink)if__name__=="__main__":logging.basicConfig(level=logging.INFO,format="%(levelname)s [%(asctime)s] %(message)s")app.run(until="1 hour")
shop takes the sink as an argument, and the script passes it sink from the Builder file, with its batch size in batch_sizes:
defpack_order(env,packers,sampler,key,task_id):path_id="Shop.service.packing"env.publish_event(key,{"path_id":path_id,"status":"queued"},"pack_order",task_id)withpackers.request()asunit:yieldunitenv.publish_event(key,{"path_id":path_id,"status":"started"},"pack_order",task_id)yieldenv.timeout(sampler.sample(env.registry.get_config(path_id)))# The finished event is the order recordorder={"order_id":task_id,"amount":19.99}env.publish_event(key,order,"pack_order",task_id)deforders(env,packers,sampler):gap=env.registry.get_config("Shop.arrival.orders")fornumberinitertools.count(1):yieldenv.timeout(sampler.sample(gap))env.process(pack_order(env,packers,sampler,f"orders-{number}",number-1))defshop(sink,factor:float=0.0)->DynamicRealtimeEnvironment:"""The shop, appending to the table of the sink given, as fast as it can."""env=DynamicRealtimeEnvironment(factor=factor)env.registry.register_sim_parameter(PARAMS)# One commit per flush, so the sink gets a large batch of its ownenv.setup_egress([sink],batch_sizes=[BATCH_SIZE])packers=DynamicResource(env,"Shop","packers")env.process(orders(env,packers,Sampler(seed=42)))returnenv
Read the Table Back
read_events loads the table through the same catalog and reads every row:
defread_events(catalog:dict,table:str="shop.events"):"""Every row of `table`, as one PyArrow table."""frompyiceberg.catalogimportload_catalogproperties={key:valueforkey,valueincatalog.items()ifkey!="name"}return(load_catalog(catalog["name"],**properties).load_table(table).scan().to_arrow())
A second run appends its rows to the same table. To replace the rows of an earlier run instead, give the sink upsert_keys, as Egress shows.
Check the Result
Run python iceberg_tables.py, python iceberg_tables_low_level.py or desflow run iceberg_tables.yaml. With no variables set, the catalog is SQLite, and its database and table files go in the folder iceberg. With the catalog profile up,
writes through the REST catalog instead. In every form, shop.events gains one snapshot, with a row for each event: the queued and started events of each order, with path_id and status, and its finished event, with order_id and amount. A seeded run writes the same events in every form. Only timestamp differs, because it is the clock time at the start of the run plus the simulation time.
Full Files
Every form uses the catalog and the sink of iceberg_tables.py, so keep the files in one folder.
# The Write to Iceberg Tables guide: the shop appends an hour of orders to the Iceberg# table shop.events, as fast as the machine allows. ICEBERG_CATALOG picks the catalog# (sqlite, the default, rest, postgres or glue). Needs iceberg_tables.py and# iceberg_tables_logic.py beside it. Run it with: desflow run iceberg_tables.yamlsimulation:sim_id:Shopfactor:0.0random_seed:42resources:packers:{current_cap:1, max_cap:1}services:packing:{dist:constant, value:1.5}arrivals:orders:{dist:exponential, rate:0.5, spawn:pack_order}tasks:pack_order:service:packingresource:packers# The finished event is the order recordpayload:!pythoniceberg_tables_logic.order_recordegress:# Every event as a row of shop.events, which the sink creates from the first batch-type:Icebergconfig:default_table:shop.eventscatalog:!pythoniceberg_tables.catalog# One commit per flush, so the sink gets a large batch of its ownbatch_size:200000run:until:1 hour
"""Python for iceberg_tables.yaml: the order record each packed order publishes. The catalogis that of iceberg_tables.py, which must be beside the blueprint."""deforder_record(task_id,context,entity):"""The finished event of a packed order: the order record."""return{"order_id":task_id,"amount":19.99}
"""The catalogs, sink and shop of the Write to Iceberg Tables page.The shop appends an hour of orders to the Iceberg table shop.events, as fast as themachine allows. ICEBERG_CATALOG picks the catalog: sqlite (the default), rest, postgres orglue. Run it with `python iceberg_tables.py`; rest needs `odctl up catalog`, postgresneeds `odctl up postgres`, and glue needs AWS credentials and ICEBERG_WAREHOUSE.iceberg_tables.yaml and iceberg_tables_low_level.py are the same shop in YAML and thelow-level API, and use the catalog and the sink of this file."""# Each section carries its own imports, as the page shows it.# ruff: noqa: E402importosfrompathlibimportPath# The SQLite database and the local table files go in the folder iceberg/FOLDER=Path("iceberg").resolve()FOLDER.mkdir(exist_ok=True)# One mapping of PyIceberg catalog properties for each catalogCATALOGS={# A SQL catalog on SQLite, with no service, for a first run"sqlite":{"type":"sql","uri":f"sqlite:///{FOLDER}/catalog.db","warehouse":f"file://{FOLDER}/warehouse",},# The REST catalog of `odctl up catalog`, with the table files on its SeaweedFS"rest":{"type":"rest","uri":"http://127.0.0.1:8181","warehouse":"s3://warehouse/","s3.endpoint":"http://127.0.0.1:8333","s3.access-key-id":"user","s3.secret-access-key":"password","s3.region":"us-east-1",},# A SQL catalog on the PostgreSQL of `odctl up postgres`, with local table files"postgres":{"type":"sql","uri":"postgresql+psycopg2://user:password@127.0.0.1:5432/odctl","warehouse":f"file://{FOLDER}/warehouse",},# The AWS Glue Data Catalog, with the table files in S3 and the standard AWS settings"glue":{"type":"glue","glue.region":"us-east-1","s3.region":"us-east-1","warehouse":os.getenv("ICEBERG_WAREHOUSE","s3://my-bucket/warehouse"),},}name=os.getenv("ICEBERG_CATALOG","sqlite")catalog={"name":name,**CATALOGS[name]}fromdesflowimportIcebergStorageEgressdeficeberg_sink(catalog:dict,table:str="shop.events")->IcebergStorageEgress:"""Every event as a row of `table`, which the sink creates from the first batch."""returnIcebergStorageEgress(catalog=catalog,default_table=table)sink=iceberg_sink(catalog)# One commit per flush, so the sink gets a large batch of its ownBATCH_SIZE=200_000importloggingfromdesflowimportSimulationContextdefbuild_shop(sink:IcebergStorageEgress)->SimulationContext:"""The shop, appending to the table of the sink given, as fast as it can."""app=(SimulationContext(sim_id="Shop",factor=0.0,random_seed=42).add_resource("packers",current_cap=1,max_cap=1).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="exponential",rate=0.5).add_egress(sink,batch_size=BATCH_SIZE))@app.task(service_id="packing",resource_id="packers")defpack_order(task_id:int,entity):# The finished event is the order recordreturn{"order_id":task_id,"amount":19.99}@app.arrival_loop("orders")deforders(context:SimulationContext):whileTrue:order=yieldcontext.wait_for_arrival("orders")context.spawn(pack_order(context.next_instance("pack_order"),entity=order))returnappapp=build_shop(sink)if__name__=="__main__":logging.basicConfig(level=logging.INFO,format="%(levelname)s [%(asctime)s] %(message)s")app.run(until="1 hour")defread_events(catalog:dict,table:str="shop.events"):"""Every row of `table`, as one PyArrow table."""frompyiceberg.catalogimportload_catalogproperties={key:valueforkey,valueincatalog.items()ifkey!="name"}return(load_catalog(catalog["name"],**properties).load_table(table).scan().to_arrow())
"""The Write to Iceberg Tables guide, low-level API: the shop appends an hour of ordersto the Iceberg table shop.events, as fast as the machine allows.The catalog and the sink are those of iceberg_tables.py, which must be beside thisscript. ICEBERG_CATALOG picks the catalog: sqlite (the default), rest, postgres or glue.Run it with `python iceberg_tables_low_level.py`."""importitertoolsimportloggingfromiceberg_tablesimportBATCH_SIZE,sinkfromdesflowimport(CapacityConfig,DistributionConfig,DynamicRealtimeEnvironment,DynamicResource,Sampler,SimParameter,)PARAMS=SimParameter(sim_id="Shop",arrival={"orders":DistributionConfig(dist="exponential",rate=0.5)},service={"packing":DistributionConfig(dist="constant",value=1.5)},resources={"packers":CapacityConfig(current_cap=1,max_cap=1)},)defpack_order(env,packers,sampler,key,task_id):path_id="Shop.service.packing"env.publish_event(key,{"path_id":path_id,"status":"queued"},"pack_order",task_id)withpackers.request()asunit:yieldunitenv.publish_event(key,{"path_id":path_id,"status":"started"},"pack_order",task_id)yieldenv.timeout(sampler.sample(env.registry.get_config(path_id)))# The finished event is the order recordorder={"order_id":task_id,"amount":19.99}env.publish_event(key,order,"pack_order",task_id)deforders(env,packers,sampler):gap=env.registry.get_config("Shop.arrival.orders")fornumberinitertools.count(1):yieldenv.timeout(sampler.sample(gap))env.process(pack_order(env,packers,sampler,f"orders-{number}",number-1))defshop(sink,factor:float=0.0)->DynamicRealtimeEnvironment:"""The shop, appending to the table of the sink given, as fast as it can."""env=DynamicRealtimeEnvironment(factor=factor)env.registry.register_sim_parameter(PARAMS)# One commit per flush, so the sink gets a large batch of its ownenv.setup_egress([sink],batch_sizes=[BATCH_SIZE])packers=DynamicResource(env,"Shop","packers")env.process(orders(env,packers,Sampler(seed=42)))returnenvif__name__=="__main__":logging.basicConfig(level=logging.INFO,format="%(levelname)s [%(asctime)s] %(message)s")env=shop(sink)# rows of shop.events in the catalog ICEBERG_CATALOG namestry:env.run(until=3600)finally:env.teardown()