Ontul Flow
Ontul Flow is the visual, governed way to build and operate streaming pipelines in Ontul. A
flow is a named, persisted source → transform → sink streaming job you manage from the Admin
UI (Analytics → Flow) or the REST API — register it, start/stop it, watch its live metrics and
logs, preview the data it produces, and review its run history.
Under the hood a flow is an Ontul streaming job: the Flow page simply persists the job
definition and submits it as SUBMIT STREAMING. Everything the Streaming Guide documents
about the engine (Flink-style continuous processing, checkpoints, exactly-once for transactional
sinks, the exchange manager) applies to flows.
Concepts
| Flow | A named pipeline definition, persisted in the replicated cluster metadata store (flow.def.<name>). Survives restarts. |
| Owner | The user who created the flow. Admins see and manage every flow; other users see and manage only the flows they own. |
| Run | One execution of a flow. Each Start creates a run with its own job id; the run is kept in the flow's history so start/stop cycles accumulate. |
| Status | READY (registered, never run / stopped), RUNNING, STOPPED/KILLED, FAILED. |
Runs correlate to a flow by a stable job name flow-<name>, so the Flow list, metrics and history are
consistent no matter which master serves the request.
Lifecycle
create / edit Start Stop
(drag-drop or YAML/JSON) ─────▶ RUNNING ─────▶ STOPPED ──┐
Save (READY) ◀───── (read-only) ◀──── │
▲ │
└──────────────────────────────────────────────────┘
edit only when NOT running
- Create / edit — build the pipeline on the canvas (pick a source and sink connector, configure each node) or paste a YAML/JSON spec, then Save. A flow is editable only when it is not running; a running flow is read-only (Stop it to edit).
- Start — deploys the flow to the cluster (IAM-enforced, see below). Status becomes
RUNNING. - Stop — cancels the run cluster-wide; the run is retained in history.
- Delete — removes the flow definition (run history is kept for audit).
The Admin UI Flow page
The first screen is the flow list: name, pipeline summary, owner, status, records, and Start/Stop/Delete actions. New flow opens the editor; clicking a flow opens it for viewing/editing.
The editor has three areas:
- Connector rail (left) — drag a source or sink connector onto the matching node, or click it. Also shows the running flows.
- Canvas (center) — the
source → transform → sinkpipeline. Click a node to configure it in the inspector; click a source/sink node to preview its rows in the Data tab. Below the canvas a dock gives five tabs:- Spec — the generated
SUBMIT STREAMINGconfig as JSON or YAML, editable. Apply to canvas parses your edits back onto the nodes, so you can author a flow entirely by pasting a spec. - Data — a live preview of the source/sink table rows (Iceberg/Ontul tables via SQL, JDBC sinks queried directly).
- Metrics — status, source phase, records processed, and an ingestion-throughput sparkline.
- Logs — the running job's log, streamed and auto-scrolled (like the cluster topology log viewer).
- History — every run of this flow with start/end time, duration, records and who ran it.
- Spec — the generated
- Inspector (right) — per-node configuration.
Connectors
Every connector shown is backed by a real engine StreamSource / StreamSink.
Sources
| Source | Description | Key settings |
|---|---|---|
| Kafka | Consume a topic | connection (KAFKA), topic, format (json/avro), consumer group |
| Database CDC | Debezium change-data-capture | database (postgres/mysql/sqlserver/oracle/db2), connection, tables, snapshot (initial/never) |
| Iceberg | Incremental read of an Iceberg table | table (catalog.schema.table), mode (append or changelog — the latter emits I/U/D with a __op column), start (latest — only snapshots committed after the flow starts, or all — the whole table first; earliest is accepted as a synonym for all) |
| File / object | Poll an S3 prefix for new newline-delimited objects | connection (S3), path (s3://bucket/prefix/), format |
| NeorunBase | Poll a NeorunBase (Lakebase OLTP) table | mode (rest/jdbc), endpoint/JDBC URL, poll query, cursor column |
All five relational Debezium connectors are bundled — Postgres, MySQL/MariaDB, SQL Server, Oracle and Db2 — with their JDBC drivers. Credentials resolve from the registered connection by id, so no database password lives in the flow spec.
Sinks
| Sink | Description | Write modes |
|---|---|---|
| Iceberg | Write to an Iceberg v2 table | append · upsert · CDC apply (latest row per key, hard/soft/ignore delete) |
| JDBC | Write to a relational table (Postgres/MySQL/…) | append · CDC apply (upsert on c/u/r, delete on d, by key) |
| Kafka | Fan out to a topic | at-least-once or transactional |
| REST / webhook | HTTP POST batches of rows as JSON | at-least-once |
| Elasticsearch | Bulk index | idempotent by doc id |
| NeorunBase | Write to NeorunBase (Lakebase OLTP) | rest/jdbc |
| Console | Print rows to the worker log | debug |
Iceberg, JDBC (without a window) and transactional Kafka (from a Kafka source) are exactly-once across failures and Stop/Start; the others are at-least-once and never drop a record — see Delivery guarantees.
CDC apply — Iceberg & JDBC
When the source carries change events (a CDC source, or an Iceberg changelog source), the sink can
materialize a replica instead of appending every event. Rows carry an operation column (__op:
c/u/r = upsert, d = delete); the sink applies them by primary key:
- Delete handling —
hard(equality-delete tombstone, row removed at the next commit),soft(row kept, a deleted-flag column set and a deleted-at timestamp stamped), orignore(drop deletes). - Slowly-changing dimension (Iceberg) — Type 1 overwrites in place; Type 2 closes the
current version (sets
valid_to+is_current=false) and appends a new version, using the CDC before image so no re-read is needed.
SUBMIT STREAMING config
The Flow page persists and submits this JSON (also reachable directly via
POST /v1/api/sql with SUBMIT STREAMING <config>). A source is either a legacy kafka node or a
generic source node with a type; the sink is a sink node with a type (plus a write block for
Iceberg CDC apply).
{
"source": {
"type": "cdc",
"connector": "postgres",
"connectionId": "pg-sales",
"tables": ["public.orders"],
"snapshot": "initial"
},
"operations": [
{ "type": "FILTER", "value": "amount > 50" },
{ "type": "SELECT", "value": "order_id, customer_id, amount" }
],
"sink": { "type": "table", "table": "ice.sales.orders", "schemaEvolution": "add" },
"errorSink": { "type": "table", "table": "ice.sales.orders_rejected" },
"write": {
"mode": "cdc",
"keys": ["order_id"],
"delete": "soft",
"opColumn": "__op",
"deletedFlag": "is_deleted",
"deletedAt": "deleted_at"
},
"commitIntervalMs": 1000,
"numWorkers": 2,
"durationMs": 9999999999
}
A Kafka → JDBC append flow is simpler:
{
"kafka": { "connectionId": "kafka-prod", "topic": "orders", "format": "json", "groupId": "orders-cdc" },
"operations": [{ "type": "FILTER", "value": "amount > 50" }],
"sink": {
"type": "jdbc",
"jdbcUrl": "jdbc:postgresql://pg:5432/app",
"tableName": "orders_sink",
"username": "app", "password": "…",
"batchSize": 1
},
"commitIntervalMs": 1000, "numWorkers": 1
}
The write block may sit at the top level, as above, or inside the sink node as sink.write; the
Admin UI's Flow builder saves it as sink.write, and a block inside the sink wins when both exist.
History-keeping SCD Type 2 (valid-from / valid-to rows) is not implemented; CDC apply keeps the latest
row per key.
For a JDBC CDC-apply sink, put mode/opColumn/keys on the sink node itself (JDBC does not use
a separate write block).
Data quality — rejected records
A record a flow cannot decode (malformed JSON, an undecodable Avro payload, an empty value) is
rejected: it never reaches the sink, and it is counted. The count is visible as Rejected
in the Flow list, the header metrics, the Metrics tab and each run's history row, and the reason is
logged (json_parse, avro_decode, empty_payload).
A rejected record is not a processed record
Records counts rows written to the sink; Rejected counts rows that were dropped before it.
They never overlap, so Rejected > 0 always means data did not arrive at the target.
To keep the rejected records instead of only counting them, name a quarantine table with
errorSink (Admin UI: the sink panel's Rejected records → Quarantine table field):
{
"source": { "type": "kafka", "connectionId": "kafka-prod", "topic": "orders", "format": "json" },
"sink": { "type": "table", "table": "ice.sales.orders" },
"errorSink": { "type": "table", "table": "ice.sales.orders_rejected" }
}
The quarantine table has a fixed, source-independent schema — it holds records whose own shape could not be trusted:
| column | type | |
|---|---|---|
__flow |
string | the flow (job) that rejected the record |
__reason |
string | json_parse / avro_decode / empty_payload / schema_mismatch |
__error |
string | the underlying failure message |
__source |
string | where it came from, e.g. orders-0@1523 |
__payload |
string | the raw record, verbatim, for replay (truncated at 64 KB) |
__ts |
bigint | rejection time, epoch millis |
It is created on first write, appended at each checkpoint, and governed like any other write target:
the flow's owner needs data:Insert on it, and it appears in the flow's audit tables. Quarantining
is best-effort — if the quarantine table itself cannot be written, the flow keeps running and the
records stay counted, with an error in the log.
Schema drift
A source's shape is not frozen: someone runs ALTER TABLE … ADD COLUMN upstream, or a producer
starts emitting a new JSON field. sink.schemaEvolution decides what a flow does about it:
| mode | behavior |
|---|---|
add (default) |
A column the target table does not have is added to it, and int→long / float→double are promoted, before the batch is written. |
none |
The table schema wins — a new source column is ignored. |
strict |
Drift fails the flow instead of guessing. Use when the target schema is a contract. |
Only additive, read-compatible changes are ever applied — exactly the set Iceberg can make without
rewriting committed data files. A column the source dropped is deliberately left in the table
(later rows carry NULL), because one absent field in one batch is not evidence the column is gone.
Anything narrowing or incompatible is refused: strict fails, otherwise the table keeps its type.
Schema drift applies to the Iceberg sink. A JDBC sink writes to a table whose schema the target database owns — add the column there.
Lag
Lag answers "is this flow keeping up":
- records — Kafka consumer lag (log end offset − our position), summed over the partitions this flow's workers own.
- time — how old the newest record processed is. For CDC this is the source database's own change timestamp, and it is the only meaningful lag for a change log (there is no row count to be behind by).
Both are surfaced in the Flow list and Metrics tab (1,204 rec · 2.3s), in the periodic progress line
in the flow's log, and on the job status API as sourceLag / sourceLagMs. A flow's worker count is
fixed for a run: to scale one out, Stop → change numWorkers → Start. A flow keeps its checkpoints
under flow-<name>, so the new run resumes from the last completed checkpoint — offsets and window
state, redistributed by key over the new worker count. Nothing is lost; an exactly-once sink also
reprocesses nothing, an at-least-once sink may receive the records read after that checkpoint again.
Set "startFrom": "fresh" in the flow config to start over instead.
Keeping the sink table fast
A streaming flow commits on an interval, so it naturally produces many small files. Ontul compacts them in the background, concurrently with the flow — no need to stop ingestion:
- Enable per-table compaction (cron or fixed interval) under Iceberg → Maintenance in the Admin
UI, or with
ALTER TABLE … EXECUTE optimize(…). - Concurrent-write safety is built in: a cooldown window excludes the files an active writer is
still appending to,
skip_active_partitionsleaves a partition a flow is currently writing untouched, and commits retry with a sequence-number margin.
See Iceberg Maintenance for the full set of operations (compaction, expire snapshots, remove orphan files, rewrite position deletes) and their tuning.
Governance (IAM)
Flows are governed like the rest of Ontul — see IAM.
- Starting a flow runs it as the user and enforces the same table-level RBAC as SQL DML: an
Ontul-catalog
tablesink needsdata:Insert(plusdata:Update/data:Deletefor a cdc/upsert write mode); an Ontul-catalogtablesource needsdata:Select. External systems (Kafka/CDC/JDBC/…) are reached through their registered connection credentials. - Visibility & management are owner-scoped: admins see and manage all flows; a non-admin user sees and can start/stop/edit/delete only the flows they own.
Lineage & audit
Starting a flow is captured by Ontul governance exactly like SQL DML — no configuration:
- Audit Log — the start is recorded as a
data:Writeevent with the sink as theresourceand every source and sink dataset intables, alongside who started it and the fullSUBMIT STREAMINGconfig. An IAM-refused start is recorded withdecision=DENY. - Data Lineage — a lineage edge is recorded from the flow's
source(s) → sink (and emitted as an OpenLineage event when
ontul.lineage.openlineage.urlis set). Ontul-catalog tables appear under theircatalog.schema.tablename so a flow's edge lines up with SQL-DML lineage; external systems use a scheme-prefixed dataset id —kafka://<topic>,cdc://<connector>/<table>,jdbc://<table>,es://<index>,http://<endpoint>,neorunbase://<table>, or the S3 path for a file source.
Both are best-effort and never block or fail the flow submission.
Storage & failover
A flow is a distributed streaming job; if a worker dies, the job restarts on the live workers from its
last completed checkpoint via the exchange manager (see Checkpointing). For real deployments run the exchange (and job logs) on S3
so state is not tied to a single worker's disk — configurable at runtime from Storage in the Admin
UI, applied cluster-wide without a restart. See Configuration for the
ontul.exchange.*, ontul.job.logs.* and ontul.flow.* properties.
See also
- Streaming Guide — the engine, SUBMIT STREAMING, SDK APIs, windowing, Iceberg sink modes.
- Kafka integration, Connections, IAM, Iceberg integration.
- Iceberg Maintenance — compaction that runs alongside a live flow.