Bolt for writing data to Apache Iceberg tables directly from a Storm topology — no Kafka Connect or Spark job in between — with atomic commits and at-least-once delivery.
Read this before anything else; it is the part that decides whether this module fits.
This module does not promise exactly-once. Exactly-once would need a deterministic identity of the input — the same batch content under the same identifier on replay — and that comes from the source, not from the sink. A general-purpose module cannot assume every user has a replayable, deterministically addressed source, so the claim is not made. An extractor SPI for sources that can do better is a possible future addition, not a current guarantee.
The target must be an append-only, format-version-2 Iceberg table. Row-level deletes, upserts, and merge-on-read are out of scope.
Map<String, String> catalogProps = new HashMap<>();
catalogProps.put("type", "rest");
catalogProps.put("uri", "http://rest-catalog:8181");
IcebergOptions options = new IcebergOptions.Builder()
.withCatalogProperties(catalogProps)
.withTable("db.events")
.withCommitIntervalBytes(128L * 1024 * 1024)
.build();
TopologyBuilder builder = new TopologyBuilder();
builder.setSpout("events", spout, 2);
builder.setBolt("iceberg", new IcebergBolt(options), 4)
.shuffleGrouping("events");
Config conf = new Config();
// Bounds how long a partial batch waits when the stream goes quiet.
conf.put(Config.TOPOLOGY_TICK_TUPLE_FREQ_SECS, 30);
The catalog properties are passed verbatim to Iceberg's CatalogUtil.buildIcebergCatalog(...),
so every Iceberg catalog works with its standard configuration keys: type = hive, hadoop,
rest, or catalog-impl for Glue, Nessie, JDBC, etc. The catalog implementations themselves are
not pulled in transitively — see Dependencies.
| Option | Default | Description |
|---|---|---|
withCatalogProperties(Map) |
required | Iceberg catalog configuration |
withTable(String) |
required | Target table identifier, e.g. db.events |
withRecordMapper(RecordMapper) |
FieldNameRecordMapper |
Tuple → Record conversion |
withFileFormat(FileFormat) |
PARQUET |
Data file format |
withTargetFileSizeBytes(long) |
table property write.target-file-size-bytes |
Rolling file size |
withAutoCreate(Schema, PartitionSpec) |
disabled | Create the table on first use if missing |
withCommitIntervalRecords(int) |
1000, when no other threshold is set | Close the batch after this many tuples |
withCommitIntervalBytes(long) |
disabled | Close the batch after roughly this many bytes |
withCommitIntervalMillis(long) |
disabled | Close the batch once it has been open this long |
withGroupCommitIntervalMillis(long) |
5000 | IcebergCommitterBolt only: commit once the oldest accumulated batch is this old |
withGroupCommitMaxDataFiles(int) |
1000 | IcebergCommitterBolt only: commit once this many data files have accumulated |
withTickIntervalSecs(int) |
none — inherits topology.tick.tuple.freq.secs |
Per-component tick frequency |
withWalNamespace(String) |
none | Separates the commit WAL of deployments sharing one table |
Committing every tuple would mean one snapshot and one small file per tuple, which degrades
catalog and reader planning quickly. The bolt therefore accumulates tuples and commits them
together, closing the batch when the first configured threshold is crossed. If you configure none,
it falls back to withCommitIntervalRecords(1000) so a batch can never wait indefinitely.
Buffering costs latency and replay volume, not durability. Buffered tuples are not acked, so a worker that dies mid-batch has them replayed rather than losing them. The trade-off to weigh is how much work a crash repeats, and how long rows wait before becoming visible — not whether they survive.
Configure Config.TOPOLOGY_TICK_TUPLE_FREQ_SECS as well. withCommitIntervalMillis is evaluated
when the next tuple arrives, so on a stream that stops entirely only a tick tuple can close the
final partial batch.
Set topology.max.spout.pending comfortably above the number of tuples one batch accumulates: a
batch's tuples stay un-acked until it commits, so too low a value stalls the topology.
By default tuple fields are matched to table columns by name (FieldNameRecordMapper):
numeric values are widened to the column type, Instant / java.util.Date / epoch-millis
Long values are converted for timestamp columns, byte[] becomes ByteBuffer. A required
column with no tuple value fails the topology loudly. For anything custom (structs, lists,
renames), implement RecordMapper:
public interface RecordMapper extends Serializable {
Record map(ITuple tuple, Schema schema);
}
The mapper takes ITuple, which both a bolt's Tuple and a TridentTuple satisfy, so a mapper
can be shared if you also write to Iceberg from elsewhere.
Partitioned tables need no extra configuration: the writer derives each record's partition from
the table's current PartitionSpec and keeps one open data file per partition (Iceberg's fanout
writer), so a single batch can span any number of partitions.
Schema schema = new Schema(
Types.NestedField.required(1, "id", Types.LongType.get()),
Types.NestedField.required(2, "region", Types.StringType.get()),
Types.NestedField.required(3, "event_time", Types.TimestampType.withZone()));
PartitionSpec spec = PartitionSpec.builderFor(schema)
.identity("region")
.day("event_time")
.build();
IcebergOptions options = new IcebergOptions.Builder()
.withCatalogProperties(catalogProps)
.withTable("db.events")
.withAutoCreate(schema, spec)
.build();
The PartitionSpec above is only used when the table is auto-created; for an existing table the
spec stored in the catalog wins. A batch spread over many partitions opens many files at once —
group the stream by the partition columns (fieldsGrouping) to keep file counts down.
IcebergBolt writes and commits in the same task, so a sink at parallelism N committing every T
seconds produces N/T snapshots per second — each a metadata rewrite plus a compare-and-swap on
the catalog. Past a certain parallelism the only way to keep that load down is to commit less
often, which is to say to accept more latency.
IcebergWriterBolt and IcebergCommitterBolt break that coupling. Writers seal batches on the
same thresholds IcebergBolt uses, but instead of committing they emit one descriptor tuple
carrying the batch's data files, anchored to every tuple of the batch, and then ack those
tuples. Anchoring is what preserves the guarantee: acking an input after emitting an anchored child
does not close the spout tuple's ack tree, so the source still advances only once the descriptor
itself is acked. A single committer accumulates descriptors from every writer, appends them all in
one Iceberg commit, and acks. Commit cost stops scaling with the sink's parallelism.
builder.setBolt("iceberg-writer", new IcebergWriterBolt(writerOptions), 8)
.fieldsGrouping("events", new Fields("region"));
builder.setBolt("iceberg-committer", new IcebergCommitterBolt(committerOptions), 1)
.globalGrouping("iceberg-writer");
The committer must run at a parallelism of one behind a globalGrouping. It logs a warning if it
finds more than one task of its own component, because each task would commit independently — the
cost the split exists to remove.
The trade-offs against the monolithic bolt:
iceberg-oldest-pending-age-ms.ContentFileParser, per-column metrics included, so a wide schema sealing many partitions at once
puts hundreds of KB on the wire per seal. withGroupCommitMaxDataFiles bounds what the committer
accumulates; nothing bounds a single writer's seal but its own commit thresholds.The committer needs a tick tuple. withGroupCommitIntervalMillis is evaluated only when a
descriptor arrives, so after a lull one accumulated descriptor sits uncommitted indefinitely, its
tuples un-acked with it, until either another descriptor or a tick arrives. Configure
Config.TOPOLOGY_TICK_TUPLE_FREQ_SECS for the topology, or withTickIntervalSecs on the
committer's own options. The same applies to the writers' withCommitIntervalMillis and to
IcebergBolt's; each of the three bolts declares whatever withTickIntervalSecs it was given.
The window between "the data files are durable" and "the table references them" is the only place a crash can do damage, and a write-ahead log closes it.
<table location>/metadata/_storm_wal/<topologyName>/<componentId>/<taskIndex>/, through the
table's own FileIO, carrying a freshly minted commit id. It lives with the table, not on
worker-local disk, so a task relaunched on another host still finds it.storm.iceberg.commit-id).The WAL belongs to whichever component commits: IcebergBolt, or IcebergCommitterBolt in the
split sink. IcebergWriterBolt writes no WAL entries, because it makes nothing visible.
On startup, before writing anything new, each committing task clears whatever its previous incarnation left behind. Every pending entry is dropped and its data files are left as orphans. An entry only survives to startup if the task died before its commit resolved, and in that case the batch was never acked, so the source replays it: appending the entry's files here would add a second copy of the rows that replay writes. For a reliable source this path can only add duplicates, never prevent loss — which is why a failed commit is settled while the batch is still in hand instead.
A failed commit is resolved immediately, while the batch is still in hand, rather than left to the next startup. The sink asks the table whether the commit landed:
CommitStateUnknownException. The batch is visible, so its tuples are acked and nothing
is replayed.The second answer is one sample, not a verdict. A REST catalog maps HTTP 500, 502, 503 and 504 to
CommitStateUnknownException, and SnapshotProducer.commit() retries only CommitFailedException,
so a commit the backend applies after the sink has looked reads as absent. The replay then writes
those rows a second time. Waiting longer narrows that window without closing it, so the sink does
not wait: duplicates are inside the contract this module offers.
Note what the WAL does and does not protect. It protects the reference to durable data, which is why atomic commits survive a crash. It does not give exactly-once: a batch that failed before its WAL entry existed is replayed from the source and written afresh, duplicates included.
A crash before step 2 leaves orphan data files. They are invisible to readers, and cleaned up by
Iceberg's standard remove_orphan_files maintenance.
This module writes; it does not maintain. A production table needs, on a schedule and from outside the topology:
remove_orphan_files — reclaims files left by crashes and failed batches.rewrite_data_files (compaction) — streaming ingestion produces more, smaller files than
batch ingestion, whatever the commit interval.expire_snapshots — one snapshot per commit adds up quickly.Skipping these degrades read planning and grows storage cost over time. Iceberg ships these as catalog procedures and as Spark actions; use whichever your platform already runs.
The bolt registers these metrics-v2 metrics per task, so they show up in whatever reporter the cluster is configured with:
| Metric | Type | Meaning |
|---|---|---|
iceberg-records-written |
counter | Records handed to the Iceberg writer |
iceberg-data-files-committed |
counter | Data files made visible by commits |
iceberg-bytes-committed |
counter | Bytes in those data files |
iceberg-commit-latency |
timer | Duration of the Iceberg commit |
iceberg-commit-failures |
counter | Commits that did not become visible |
iceberg-data-files-sealed |
counter | Data files closed and handed downstream (IcebergWriterBolt) |
iceberg-seal-latency |
timer | Duration of closing a batch's files (IcebergWriterBolt) |
iceberg-pending-data-files |
gauge | Files accumulated but not yet committed (IcebergCommitterBolt) |
iceberg-oldest-pending-age-ms |
gauge | Age of the oldest batch waiting to commit (IcebergCommitterBolt) |
iceberg-oldest-pending-age-ms is the one to alert on: a committer that has stopped making
progress shows up there long before its upstream tuples start timing out. Both gauges keep
reporting for the duration of an in-flight commit, which is when a stalled catalog shows up.
The counters follow the outcome, not the exception: an append that reported an error but whose
data is visible counts under iceberg-data-files-committed, not iceberg-commit-failures. Every
increment of iceberg-commit-failures therefore corresponds to tuples that were failed and
replayed.
A steadily rising iceberg-data-files-committed with a flat iceberg-bytes-committed is the
small-files signature: raise the commit interval, or schedule compaction.
The table metadata is refreshed between batches, so schema and partition spec evolution performed outside the topology is picked up without restarting the workers. Every file within one commit is written against the same metadata.
storm-iceberg is not bundled in the binary distributions, in line with the direction of
#8819: it is a topology-side library, and the
distribution's external/ directory is on no classpath.
It depends on the Iceberg core and format artifacts only. Catalog implementations are not pulled
in transitively — a topology writing through Glue, Nessie, or JDBC adds that catalog itself, and
one writing to S3 adds iceberg-aws and the AWS SDK. This keeps the dependency footprint of the
module small and puts the choice of object-store and catalog bindings where it belongs.
The Iceberg version is pinned explicitly rather than tracking the newest release, so the module stays buildable against Storm's Java baseline.
examples/storm-iceberg-examples contains three runnable topologies writing to a local Hadoop
catalog: IcebergBoltExampleTopology (unpartitioned), IcebergPartitionedBoltExampleTopology
(partitioned by identity(region) and days(event_time)), and IcebergSplitSinkExampleTopology
(the writer/committer split).
FileIO that supports prefix listing. Iceberg's HadoopFileIO,
S3FileIO and ResolvingFileIO all do; an exotic custom FileIO may not.IcebergBolt, each task commits independently. Iceberg resolves concurrent append commits
with optimistic retries (tune with the table property commit.retry.num-retries), but beyond
roughly 10–20 concurrent writers consider reducing the bolt's parallelism or moving to the
writer/committer split.withWalNamespace(...).