Summary
A KafkaTable's reader task dies permanently when the broker connection is lost in a way that raises out of the fetch loop. Once the reader task exits with an exception, the table is latched into a terminal failed / frozen state for the lifetime of the object — it never reconnects or recovers, even after the broker comes back and the topic is fully healthy. The only remedy is to construct a brand-new table.
Mechanism
The reader loop KafkaTable._run (kafka_table.py) is:
while True:
batches = await consumer.getmany(timeout_ms=self._poll_timeout_ms)
...
# (position()/end_offsets() calls for the catch-up gate and barriers)
There is no reconnect/retry wrapper around the loop. The only in-loop exception that is caught is IllegalStateError from position() (a transient unassignment). Any other exception escaping getmany() / position() / end_offsets() — i.e. the fatal aiokafka errors a broker disconnect can surface — propagates out of the task.
_on_reader_done (the task's done-callback) then latches the failure permanently:
def _on_reader_done(self, task):
if task.cancelled():
return
exc = task.exception()
if exc is not None:
self._failure = exc
self._failed.set()
logger.error("KafkaTable reader for topic=%s DIED (...); table is FROZEN at last applied state (... status=failed)", ...)
After this:
failure stays set forever (there is no code path that clears it or re-spawns the reader task);
- reads serve the frozen last-applied snapshot;
wait_until_caught_up() / barrier() return False (they short-circuit on self._failure);
status is permanently "failed".
Nothing restarts the reader when the broker returns, so a transient outage becomes a permanent death.
The in-loop comment states the intent — "transient outages don't raise — getmany returns empty" — but in practice a broker disconnect (broker restart, network partition, host sleep/resume) can raise a fatal error out of the fetch/position calls, and that path is terminal.
Reproduction
- Start a broker and create a topic.
- Construct a
KafkaTable over that topic and await table.start() — let it catch up (is_caught_up / status == "caught_up").
- Sever the broker connection in a way that raises out of the loop, not a clean idle: kill/restart the broker process, or drop the network (e.g. a laptop sleep/resume), while the reader is live.
- Observe the reader task exit with an exception and
_on_reader_done log ... DIED ...; table is FROZEN ... status=failed.
- Bring the broker back and let the topic become fully reachable again.
Also observed in the wild: a long-lived reader left running for hours across network-loss / sleep cycles reaches the same terminal state.
Failing case (observed)
- After step 5, with the broker and topic healthy again, the table stays dead:
failure is still set, status == "failed", wait_until_caught_up() / barrier() keep returning False, and reads serve stale (frozen) data indefinitely.
- A freshly constructed
KafkaTable over the same topic and broker catches up and reads correctly — proving the broker, topic, and data are healthy and the fault is confined to the dead reader object.
- No reconnection or self-recovery ever occurs for the lifetime of the process holding that table.
Downstream impact: consumers that cache a single long-lived reader inherit this permanent death. For example, calfkit's client.mesh (ControlPlaneView over a KafkaTable) surfaces it as a permanent MeshUnavailableError(reason="reader_dead") that only a process restart clears.
Environment
- ktables 1.1.1
- aiokafka 0.13.0
- Python 3.12
- First observed 2026-07-03.
Summary
A
KafkaTable's reader task dies permanently when the broker connection is lost in a way that raises out of the fetch loop. Once the reader task exits with an exception, the table is latched into a terminalfailed/ frozen state for the lifetime of the object — it never reconnects or recovers, even after the broker comes back and the topic is fully healthy. The only remedy is to construct a brand-new table.Mechanism
The reader loop
KafkaTable._run(kafka_table.py) is:There is no reconnect/retry wrapper around the loop. The only in-loop exception that is caught is
IllegalStateErrorfromposition()(a transient unassignment). Any other exception escapinggetmany()/position()/end_offsets()— i.e. the fatal aiokafka errors a broker disconnect can surface — propagates out of the task._on_reader_done(the task's done-callback) then latches the failure permanently:After this:
failurestays set forever (there is no code path that clears it or re-spawns the reader task);wait_until_caught_up()/barrier()returnFalse(they short-circuit onself._failure);statusis permanently"failed".Nothing restarts the reader when the broker returns, so a transient outage becomes a permanent death.
The in-loop comment states the intent — "transient outages don't raise — getmany returns empty" — but in practice a broker disconnect (broker restart, network partition, host sleep/resume) can raise a fatal error out of the fetch/position calls, and that path is terminal.
Reproduction
KafkaTableover that topic andawait table.start()— let it catch up (is_caught_up/status == "caught_up")._on_reader_donelog... DIED ...; table is FROZEN ... status=failed.Also observed in the wild: a long-lived reader left running for hours across network-loss / sleep cycles reaches the same terminal state.
Failing case (observed)
failureis still set,status == "failed",wait_until_caught_up()/barrier()keep returningFalse, and reads serve stale (frozen) data indefinitely.KafkaTableover the same topic and broker catches up and reads correctly — proving the broker, topic, and data are healthy and the fault is confined to the dead reader object.Downstream impact: consumers that cache a single long-lived reader inherit this permanent death. For example, calfkit's
client.mesh(ControlPlaneViewover aKafkaTable) surfaces it as a permanentMeshUnavailableError(reason="reader_dead")that only a process restart clears.Environment