Concluded·7 Aug 2026 · 4 min read

Event Streams to TimescaleDB: the config recipe, five silent failures, and a loss-accounted kill battery

Ignition 8.3.8 · Event Streams 1.3.8 · Redpanda v26.2.1 × 3 (StatefulSet, RF3) · Redpanda Connect 4.103.1 · TimescaleDB pg16 · k3s

Last month's rig proved the modules exist. This one makes them carry data, and the distance between those two statements turned out to be five silent failure modes and a set of config shapes documented nowhere outside the module jars.

The rig

ProducerIgnition 8.3.8, Event Streams 1.3.8, project SparkplugFleet
Streamsplant-live (3 system tags, per-change) · plant-historian (Performance/* wildcard, 5 s debounce)
BrokerRedpanda v26.2.1 × 3, StatefulSet, topics RF3 (plant.tags.live 6p, plant.historian 3p)
ConsumerRedpanda Connect 4.103.1 in-cluster, group tsdb-historian, sql_raw INSERT
HistorianTimescaleDB pg16 hypertable tag_history on a separate host
ConfigEverything file-based in the project except the Service Connector (UI)

The heartbeat instrument: [System]Gateway/CurrentDateTime changes once per second, so stored history audits itself. Count rows in 10-second buckets; ten is health, anything less is loss.

The config recipe the docs don't give you

Event streams are JSON resources at projects/<proj>/com.inductiveautomation.eventstream/event-streams/<name>/config.json. Authoring them by hand works, but the shapes below came from javap on the module jars, not from documentation.

"type":"com.inductiveautomation.connectors.kafka.eventstream.handler"full bytecode string — shorter forms are rejected
"config": {
"connector":"redpanda"plain string — names the Service Connector
"topic":{ "data": "plant.tags.live", "type": "STATIC" }ExpressionValue, not a string
"key":{ "data": "{event.metadata.tagPath}", "type": "EXPRESSION" }tag identity lives in METADATA
"value":{ "data": "toStr({event.data.value})", "type": "EXPRESSION" }raw value coerces to binary; toStr() fixes it
}
The Kafka handler entry as it must actually be written. Every load-bearing shape here came from javap on the module jars, not from documentation.

The parts that cost a restart each to learn:

  • Handler type ID is the full string com.inductiveautomation.connectors.kafka.eventstream.handler. Two plausible shorter forms both fail with No handler type registered.
  • sourceEncoder, batch, filter, transform, onError are all mandatory. Each missing one is a separate NullPointerException at stream creation, surfaced one restart at a time.
  • topic, key, value, partition, timestamp are ExpressionValue objects{"data": "...", "type": "STATIC" | "EXPRESSION"}. A bare string parses without complaint and produces nothing.
  • Tag identity lives in metadata, not data. The event's data payload is the qualified value; tagPath, isInitial, previousValue are metadata. So the key is {event.metadata.tagPath} and a readable payload is toStr({event.data.value}) — raw {event.data.value} coerces through a type-keyed encoder registry into binary IEEE-754 doubles.
  • Batch timers set to 0 are disabled, not fast. With debounceMs and maxWaitMs at 0, the stage flushes only at maxQueueSize: 1000 events at 1 ev/s is a sixteen-minute pipe that looks completely dead.

Deploying file changes: the two-scan cycle

8.3 removed the project scan interval, so the gateway never watches its own disk. Changes apply through Platform → Projects → Scan File System, and a changed stream needs the cycle twice: the first scan tears the running stream down, the second one starts the replacement.

1edit config.json on diskgatewaysees nothing — no filesystem watching in 8.3
2Scan File SystemstreamSHUT DOWN — 'Shutdown event stream' logged, no restart follows
3touch the file againgatewaya second diff is required for the next scan to act
4Scan File SystemstreamSTARTED — new config live, watermarks move
Deploying a file-based stream change. One scan is not enough: the first only tears down the old stream, and nothing in the log tells you a second is needed.

Miss the second scan and the stream stays down with nothing in the log, which belongs in the next section.

Five silent failures, all reproduced

  1. Boot-order race. Event Streams starts before the Kafka Connector module registers its handler type, so a file-based stream with a Kafka handler can fault at boot with No handler type registered. Intermittent across boots.
  2. No filesystem watching. External edits are invisible until a manual scan or an API-key scan call. kubectl cp alone changes nothing.
  3. Shutdown-without-restart. The first scan after a change logs Shutdown event stream and stops; no start attempt follows until another scan with a diff.
  4. failureStrategy: IGNORE hides delivery errors completely. The handler reports GOOD with error count 0 while producing nothing.
  5. A malformed handler entry unregisters the whole stream. No fault, no log line; the stream simply no longer exists. This is the one that turns config typos into invisible outages.

The theme is the same one every plant integration eventually teaches: these pipelines do not crash, they lie. A green stage status is not evidence; a number that changed is.

The running pipeline, measured

source
Tag Event
GOOD
encoder-1
JsonObject
GOOD
filter
DISABLED
transform
DISABLED
batch
1 s window
GOOD
handler-1
Kafka
GOOD
plant-live as the gateway reports it: every event walks this line left to right. The per-stage status page (and its log panel) is where the truth lives when the pipe goes quiet.

End-to-end latency, event timestamp to database row, over a fresh two-minute window of 235 rows:

p50avgp95max
650 ms941 ms5.3 s10.3 s

The tail is the historian stream's deliberate 5-second debounce, not jitter. Steady-state consumer lag: 3–4 messages across 9 partitions.

Kill battery, loss-accounted

broker kill
10
10
10
10
10
10
0 rows lost
historian down 60 s
10
10
10
10
10
10
0 rows lost
gateway restart
10
10
7
2
10
10
≈45 s gone
10-second buckets around each event · full cell = nothing lost
The battery on one axis. Each cell is a 10-second bucket of the 1 Hz heartbeat; a full cell is 10/10 ticks stored. Only the producer's own restart tears the record.

Broker kill. kubectl delete pod redpanda-1 mid-flow. Leadership moved sub-second, the pod rejoined in ~45 s, heartbeat read 10/10 in every bucket through the whole event. Zero loss, and zero symptoms downstream.

Historian outage. docker stop tsdb for 60 s. The sink logged connection refusals once per second, its bookmark froze, the log kept absorbing new records. On restart the backlog cleared in seconds with event-time stamps. Every bucket in and around the outage window: 10/10. Zero loss.

Gateway restart. kubectl delete pod gateway-0. Buckets read 23/30, 6/30, then full again: about 45 seconds of plant behaviour never measured. Nothing downstream can repair this class of loss; a reading that was never taken cannot be replayed. On this boot both streams auto-started cleanly; combined with failure mode 1 above, treat producer recovery as the untrusted step and verify it, not assume it.

Replay

A second consumer group (replay-demo, start_from_oldest: true) pointed at a fresh empty table rebuilt the complete history in under a minute: 9,950 rows in, 9,950 rows out, identical first and last timestamps, no request ever made to the gateway. The original consumer's bookmark never moved.

9,9500start< 60 sbacklog remainingcaught up · 9,950 = 9,950
The replay consumer's remaining backlog, from first poll to caught-up: 9,950 records drained in under a minute while the original consumer's bookmark never moved.

One hand-produced record through the console (Produce Record, key [Lab]Manual/TestTag, value 42.5) landed as a historian row 250 ms later with its Kafka offset in the seq column — the sink cannot tell a human from a SCADA system, which is exactly the property that makes the log the plant's source of truth rather than any single database.

What transfers

  • Author event streams as files and treat the two-scan cycle as your deploy step; it is Git-reviewable SCADA config once you know the shapes above.
  • Never ship failureStrategy: IGNORE without a downstream count check; prefer a heartbeat tag whose bucket counts audit the whole pipe for free.
  • Spend redundancy at the producer. Everything after the first hop already forgives you.

The practitioner argument built on these numbers is in the companion post.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.