Discover Transactional Workloads¶
This guide walks you through finding the transactional workloads on your source cluster before you plan a migration, so you know which topics must move to Confluent Cloud together.
Most topics in an Apache Kafka® cluster are independent of one another. You can move clickstream to Confluent Cloud today and user-profiles next month, and neither notices. Transactional workloads are the exception. When a producer writes to several topics inside one Kafka transaction, those writes commit or abort together, and downstream consumers reading with isolation.level=read_committed rely on that all-or-nothing behavior. If you cut those topics over on different days, a single transaction ends up split across two clusters, and the guarantee the application was built on is gone.
To find them, you run a small standalone utility, txn-discovery, against your source cluster, let it watch for a while, and get back a list of transaction groups: sets of topics that must move together. You can migrate everything it doesn't put in a group one topic at a time.
Proof of concept
txn-discovery is an exploratory utility that lives in the kcp repository under utils/transaction-discovery. It is not part of the kcp binary, and no kcp command reads its output. Treat the results as input to your own migration planning, and review them before acting on them.
Prerequisites and considerations¶
Cluster support
- Supported: self-managed Apache Kafka, Confluent Platform, and AWS MSK Provisioned. These expose the
__transaction_stateinternal topic for reading. - Not supported: Confluent Cloud and MSK Serverless. They don't expose
__transaction_state, so the utility stops at its first check with an error naming the topic.
Access
- A principal that can read internal topics. For the exact access control lists (ACLs), see Grant permissions.
- Network access from the machine running the utility to the source brokers.
Build tooling
- Go 1.25 and later to build the utility. The result is a single static binary with no runtime dependencies, so you can build once and copy it to whichever host can reach the cluster.
Time
- The utility reports what it observed, so it needs to run long enough to see your transactional workloads. Plan for anywhere from a few minutes (to try it out) up to a week (to catch infrequent jobs). See Choose an observation window.
What this guide covers¶
- How the utility works out which topics belong together.
- Building the utility and granting it read-only permissions.
- Running discovery and choosing how long to observe.
- Reading the terminal summary and
migration.yaml. - Understanding read-process-write groups.
- The limits of what discovery can see.
How the utility finds transaction groups¶
Kafka records every transaction in an internal topic called __transaction_state. Each time a producer with a transactional.id adds partitions to a transaction, the transaction coordinator logs it, and that log is the one place that knows which topics a given transaction touched.
txn-discovery reads __transaction_state from the earliest offset and keeps going for the length of the window. From each record it works out the transaction's footprint: the set of topics it wrote to, recorded against its transactional.id.
Grouping then comes down to one rule: topics that appear in the same transaction belong together, and the rule is transitive. Take four topics, A to D:
- One transaction writes to
AandB. - Another writes to
BandC. Donly ever appears in transactions that touch no other topic.
A and B are coupled, B and C are coupled, so all three form one group. D stands alone. Internal topics (anything starting with __) are left out before grouping. Every exactly-once application commits to __consumer_offsets, and keeping it in would chain every unrelated workload into one enormous group.
Topics a transaction only reads¶
A transaction's footprint only lists topics it writes. That's a problem for consume-transform-produce applications, the Kafka Streams and exactly-once consumer-plus-producer pattern. Such an application reads from an input topic, writes results to an output topic, and commits the consumed offsets as part of the same transaction. The footprint shows the output topic, but the input topic is invisible, even though moving the output without its input breaks the exactly-once chain at cutover.
To close that gap, the utility has two ways of finding the consumed inputs:
| Mechanism | Flag | How it works | Limits |
|---|---|---|---|
| Consumer-group enrichment | --enrich-consumer-groups |
Lists consumer groups and their committed offsets, and ties a group to a transaction through the Kafka Streams naming convention for transactional.id and group.id. |
Relies on the naming convention, so it mostly covers Kafka Streams. |
__consumer_offsets tail |
--tail-consumer-offsets |
Watches transactional offset commits and ties each one to its transaction by producer ID. | Needs read access to an internal topic, and only sees commits made during the window. |
Both are on by default. The producer ID match is exact and makes no assumptions about naming, so it also covers hand-written consumer-plus-producer applications. If the utility can't read __consumer_offsets, it falls back to the naming heuristic and says so.
Build the utility¶
The utility is a self-contained Go module inside the kcp repository.
To build for another operating system or architecture, for example when the bastion host differs from your workstation, cross-compile all targets at once:
Grant permissions¶
The utility only reads. Give the principal it connects as the following ACLs:
| Resource | ACL | Used for |
|---|---|---|
Topic __transaction_state |
READ |
Reconstructing each transaction's footprint. Required. |
Topic __consumer_offsets |
READ |
Recovering consumed inputs by producer ID. |
| Consumer groups (all) | DESCRIBE |
Recovering consumed inputs through the Kafka Streams naming convention. |
If you can't grant the last two, turn those sources off with --tail-consumer-offsets=false and --enrich-consumer-groups=false. Discovery still works from __transaction_state alone, but the utility doesn't recover consumed input topics, so read-process-write groups are incomplete.
Tip
If the first run fails with an authorization error, check these ACLs first.
Run discovery¶
Pick the invocation that matches how your cluster authenticates. Passing the password through TXN_DISCOVERY_PASSWORD keeps it out of your shell history and the process list.
MSK uses a public certificate authority (CA), so you don't need --ca-cert.
Other combinations follow the same pattern: add --tls=false to any SASL option for a plaintext listener, or --sasl none --tls=false for an unauthenticated development cluster. --tls-insecure skips certificate verification and is for local testing only.
Before it starts observing, the utility checks that it can read __transaction_state. If it can't, it exits immediately and tells you whether the problem is connectivity, credentials, or a cluster type that doesn't expose the topic.
Choose an observation window¶
--duration is the main lever on how complete the result is, and it defaults to 5 minutes. A transaction can only join a group if the utility sees it, so a nightly or weekly job that doesn't run during a short window is invisible.
| Goal | Suggested window |
|---|---|
| Check that credentials and ACLs work | 5m (the default) |
| Cover steady, always-on workloads | An hour or more |
| Include daily and weekly batch jobs | Long enough to span their schedule, for example 168h |
The utility stops early on Ctrl+C or SIGTERM and writes whatever it has seen so far, so a long run that you need to cut short isn't wasted.
Useful flags¶
| Flag | Default | Purpose |
|---|---|---|
--duration |
5m |
Total observation window. |
--interval |
30s |
How often consumed-input recovery refreshes. |
--out |
migration.yaml |
Where to write the discovered groups. |
--dry-run |
false |
Print the summary without writing the file. |
--stats-out |
off | Also write a JSON diagnostics report with every transaction footprint. |
Run txn-discovery --help for the complete list.
Read the results¶
Suppose your cluster runs four transactional applications:
checkout-servicewritesordersandorder-eventsin one transaction.fulfilment-servicewritesorder-eventsandshipmentsin one transaction.payments-processor, a Kafka Streams application, consumespayment-requestsand producespayments-approvedandpayments-ledger.audit-servicewrites only toaudit-trail.
When the window ends, the utility prints a summary.
============================================================
Transaction discovery summary
============================================================
window : 15m0s (enrichment interval 30s)
sources : transaction-state-log, consumer-groups, consumer-offsets-log
transactional producers: 4 observed
transactions : 1842 committed, 3 aborted (completions seen on the transaction-state log this window)
topics : 7 total — 6 in 2 groups, 1 individual
read-process-write : 1 group; 1 consumed input topic(s) recovered
Transaction groups (topics coupled by a shared transaction — migrate each group atomically):
group-1 (2 ids, 3 topics)
order-events
orders
shipments
group-2 (1 id, 3 topics) [read-process-write]
payment-requests <- consumed input, recovered
payments-approved
payments-ledger
Individual topics (no cross-topic transaction — can migrate one at a time): 1
audit-trail
Read-process-write (exactly-once consume-transform-produce) apps detected.
2 topic(s) are produced inside consume-transform-produce transactions.
Consumed input topic(s) recovered and folded into their group(s):
- exact producer-id correlation via __consumer_offsets: payment-requests
Read it top to bottom:
group-1is the transitive rule at work. Neither application writes to all three topics, but both write toorder-events, which links them.group-2containspayment-requestseven though the payments application never wrote to it. It was recovered as a consumed input and is flagged in the listing.audit-trailwas written to inside a transaction but never alongside another topic, so it's listed as an individual topic.
Groups are numbered by size, largest first. Long groups are truncated to 20 topics in the terminal. The full list is always in the YAML file.
Check the keep-up block
Below the summary, a Keep-up block reports how well the __transaction_state reader kept pace: records seen, lag, and any records it failed to decode. A lag that grows and never recovers, or a decode-failure warning, means the run did not observe the whole window. Rerun with a longer --duration, or from a host with a better network path to the brokers, before trusting the result.
The migration.yaml file¶
The utility writes the same result to migration.yaml, which is the file you review and keep.
generated_by: kcp txn-discovery (POC)
generated_at: "2026-10-29T10:15:00Z"
observation_window: 15m0s
groups:
- name: group-1
bootstrap_url: ""
read_process_write: false
topics:
- order-events
- orders
- shipments
transactional_ids:
- checkout-service
- fulfilment-service
- name: group-2
bootstrap_url: ""
read_process_write: true
warning: 'read-process-write group: consumed input topic(s) recovered and included via exact producer-id correlation via __consumer_offsets.'
topics:
- payment-requests
- payments-approved
- payments-ledger
transactional_ids:
- payments-processor-3f2c9a1e-7b44-4d0a-9c1d-5e8a2b6f0c11-1
individual_topic_count: 1
individual_topics:
- audit-trail
Each group lists its topics and the transactional.id values behind them, which tells you which applications to coordinate with. The utility leaves the bootstrap_url field empty for you to fill in for the target cluster. The schema is provisional and might change.
Read-process-write groups¶
A group marked read_process_write: true contains at least one consume-transform-produce application. These deserve a closer look because the utility finds the input topics indirectly, and the warning field tells you how:
- Recovered by producer ID is the strongest case. The utility saw the application commit offsets inside a transaction and matched it exactly.
- Recovered through Kafka Streams naming relies on the
transactional.idandgroup.idsharing a name. It's reliable for Kafka Streams and unreliable for anything else. - Not captured means the group has a read-process-write producer but the utility recovered no input. The warning says to review the group before cutover. Find out what the application consumes and add it to the group by hand.
If the __consumer_offsets tail was inactive, the summary tells you that non-Streams applications might be unrecovered. Treat every read-process-write group as one you verify with its owning team.
Understand what discovery can miss¶
Discovery tells you what it observed. It doesn't prove that nothing else exists.
Compaction. __transaction_state is compacted by transactional.id. A transaction's footprint can only be rebuilt from a record that still lists its partitions, and after a job finishes, its latest record has an empty partition set. If a rare job ran and was compacted down to that final record before your window opened, it can't be recovered. Reading from the earliest offset gets the most that the log retains, and a longer window helps catch jobs while they run, but neither guarantees completeness.
Reused transactional IDs. If a transactional.id was reassigned to a different workload while the old footprint records are still retained, the two workloads merge into one group. This is rare, and it errs in the safe direction: you move too much together, never too little.
Managed clusters. Confluent Cloud and MSK Serverless don't expose the data discovery depends on, so it can't run there at all.
Given those limits, a useful last step is to compare the discovered transactional_ids against what your application teams tell you they run. Anything you expect that isn't listed is worth a conversation.
Use the results in your migration plan¶
Treat the output as the shape of your cutover schedule:
- Each group is one unit of cutover. Define a single migration group in your plan that contains all of its topics, so they flip together.
- Individual topics are free to move on their own. Migrate them in whatever order suits you.
- Involve the owners. The
transactional_idsidentify the applications behind each group. Those teams need to know which topics their application's cutover is tied to.