Data lineage¶
clink can describe, for every job, the external datasets it reads from and writes
to, and emit that description to an external lineage system. The intent is
integration: an operator running a metadata catalogue or a lineage store wants to
know that "job 42 reads Kafka topic orders and writes ClickHouse table
db.sales" without parsing clink internals. The feature has two halves, kept
deliberately separate:
- Capture is engine-internal and vendor-neutral. It derives a small lineage graph from the submitted job and exposes it over HTTP and on the event bus.
- Export is pluggable. A
LineageListenertranslates the graph plus run-state into whatever an external system speaks. A built-in exporter ships the OpenLineage run-event format, which DataHub, Apache Atlas, Marquez and others ingest; bespoke backends implement the listener interface directly.
The two are connected by one in-process event and one structured graph, so the engine never depends on any particular lineage backend.
The lineage model¶
The graph is a graph of connectors and datasets, not of physical operators. It is
shaped after the OpenLineage data model (a (namespace, name) dataset identity
plus open, additive facets) so the common adapter is a near-trivial mapping.
| Type | What it is |
|---|---|
LineageDataset |
One external data entity. ns is the storage system's address (kafka://broker:9092, postgres://db:5432, s3://bucket, file); name is the entity within it (a topic, a table, an object key, a path); facets is open string metadata (connector family, record format, channel hint, delivery mode); schema is the column list (name + friendly type) when the op carries one. |
LineageVertex |
One connector: a source or a sink. Keyed by the graph-local operator id, with the stable uid when set. Source vertices also carry boundedness (bounded / unbounded / unknown). |
LineageEdge |
A coarse source-vertex to sink-vertex dependency. |
LineageGraph |
sources, sinks, edges. to_json() / from_json() round-trip it. |
Defined in include/clink/lineage/lineage_graph.hpp.
Capture¶
extract_lineage(const JobGraphSpec&) (src/lineage/lineage_graph.cpp) is a pure
function of the submitted job spec. It needs no SQL context and no runtime, so it
covers both SQL-submitted and programmatic-API jobs through one path:
- Source / sink classification mirrors the planner and
snapshot_job_graph: a source is an operator with no inputs; a sink is an operator nothing reads from. Side-output and split input refs (id::tag,id.N) resolve to the upstream id. - Edges are real reachability over the operator DAG, not all-to-all. For each source, a forward walk collects every sink it can reach. A linear job yields one edge; a two-input join yields two.
- Dataset identity comes from each operator's
type(the connector factory name) andparams(the connector options).connector_family()strips the channel and direction tokens from the factory type (kafka_2pc_sink_stringtokafka,s3_parquet_string_sourcetos3_parquet), anddataset_for()maps the family plus its params to a(namespace, name)pair.
Dataset identity normalisation¶
build_params does not carry the connector key into the operator params, so the
family is read from the factory type. The namespace and name are then drawn from
the connector's locator params:
| Connector family | namespace | name |
|---|---|---|
| kafka | kafka://<brokers> |
topic |
| pulsar / nats / rabbitmq | <scheme>://<service url or host> |
topic / subject / queue |
| kinesis / firehose / dynamodb | <scheme>://<region> |
stream / table |
| pubsub | pubsub://<project> |
topic / subscription |
| redis | redis://<host:port> |
stream / key / channel |
| postgres / mysql / clickhouse / cassandra | <scheme>://<host:port> (host/port parsed from a libpq conninfo when present) |
<database>.<table> |
| s3 / s3_parquet | s3://<bucket> |
key / prefix |
| gcs_parquet / azure_parquet / webhdfs_parquet | gs:// / azure:// / webhdfs:// + bucket or host |
key / prefix / path |
| file / filesystem / parquet | file |
path |
| iceberg | catalog uri or warehouse | <namespace>.<table> |
| delta | delta |
path / table |
| elasticsearch / opensearch / splunk / prometheus / influxdb / http | <scheme>://<host or url> |
index / job / measurement / url |
| (anything else) | <family>://<authority> from the common locator keys |
first present of topic / table / path / key / ... |
The generic fallback guarantees that a connector not enumerated above still
produces a usable identity. Adding a connector means adding a branch to
dataset_for(); the family list it mirrors lives in src/sql/physical_plan.cpp.
Lifecycle and exposure¶
The capture side rides the existing in-process EventBus
(include/clink/runtime/event_bus.hpp), the same bus that backs the
/api/v1/events SSE stream:
- At submit,
Coordinator::submit_jobpublishescoordinator.job_lineagewith the lineage graph (payload{"job_id":N,"job_name":"...","lineage":{...}}). Best-effort: a job is never failed because of lineage. - At termination, the existing
coordinator.job_completedevent carries the outcome (payload{"job_id":N,"job_name":"...","status":"...","errors":<count>}, plus an"error"string on failure). Thejob_nameanderrorkeys are additive;errorsstays a count for existing consumers.
The graph is also available to poll, for tools that prefer pull over the event stream:
GET /api/v1/jobs/:id/lineage
-> {"job_id":N,"available":true,"lineage":{"sources":[...],"sinks":[...],"edges":[...]}}
served by Coordinator::snapshot_job_lineage, a mirror of snapshot_job_graph
that reads the retained JobGraphSpec and runs the extractor. available is
false when the job exists but no graph was retained.
Column-level lineage¶
For SQL jobs, lineage also records which source column(s) feed each sink column. This is only knowable at SQL compile time (the column mapping lives in the bound plan; the Coordinator sees only the lowered operator graph), so it is captured in the planner and carried on the spec to the Coordinator:
- Capture (
src/sql/column_lineage.cpp, run fromPhysicalPlanner::compile). A recursive tracer walks the boundLogicalSinktree. For each sink output column it traces backwards to the source(table, column)it derives from:Scanis the base case;Projectparses the output'sexpr_jsonand recurses on each{"col":...}leaf;Aggregatemaps a group-key output to its source column and an aggregate output to itsinput_column;Filterpasses through;EquiJoin/IntervalJoinmap the output column ordinal to the owning child by position (left columns then right). Source datasets are identified with the samedataset_from_familyhelper the lowered-op path uses, so the captured input refs line up with the source vertices. - Carry:
JobGraphSpec::column_lineage, a JSON object keyed by sink op id, serialised with the spec (so it survives HA restart) exactly likename. - Attach:
extract_lineagereads it onto the sinkLineageDatasetas acolumn_lineagelist, round-tripped by the model'sto_json/from_json.
Each field records its output column, the inputs ({namespace, name, field}
referencing a source dataset), and a transformation: IDENTITY (a straight
copy), TRANSFORMATION (a computed expression) or AGGREGATION (through a GROUP
BY aggregate). The export side maps this to the OpenLineage columnLineage
dataset facet.
Export¶
A LineageListener (include/clink/lineage/lineage_listener.hpp) is the export
hook. Listeners are host-side components, constructed inside the clink_node
process, never on the job-plugin .so path: a .so links its own private
EventBus singleton and would never see the host's events.
flowchart LR
submit["submit_job"] -->|"coordinator.job_lineage"| bus["EventBus (host)"]
done["job termination"] -->|"coordinator.job_completed"| bus
bus --> disp["LineageDispatcher"]
disp -->|"LineageEvent"| ol["OpenLineageExporter"]
disp -->|"LineageEvent"| custom["your LineageListener"]
ol -->|"START / COMPLETE run events"| ext["OpenLineage receiver<br/>(Marquez, DataHub, Atlas)"]
coordinator["snapshot_job_lineage"] -->|"GET /api/v1/jobs/:id/lineage"| poll["polling tools"]
LineageDispatchersubscribes to the bus, reconstructs a structuredLineageEvent(JobStartedcarries the graph;JobCompletedcarries the status), and fans it out to its listeners. It runs on the publish thread, so a listener that does network I/O must not block there.LineageListenerRegistryis a named-factory registry mirroring the engine's other registries.register_builtin_lineage_listeners()registers theopenlineagefactory when the build includes the HTTP client.
The OpenLineage exporter¶
OpenLineageExporter (src/lineage/openlineage_exporter.cpp) maps a
JobStarted to a START run event (sources as inputs, sinks as outputs) and a
JobCompleted to a COMPLETE / FAIL / ABORT, correlated by a job-derived runId.
A FAIL event carries the first failure as an OpenLineage errorMessage run
facet. Datasets carry a schema facet (column name + type) when the connector
op declares one; output datasets also carry a columnLineage facet when column
lineage was captured (fields → { <out>: { inputFields:[{namespace,name,field}],
transformationType } }). Delivery is asynchronous: on_event serialises the
event and pushes it onto a bounded outbox; a worker thread POSTs from the outbox
so the publish thread is never blocked. Overflow drops the oldest queued event
and counts the drop.
The OpenLineage job name is the submitter's job name (JobGraphSpec::name),
carried through to both events; it falls back to job_<id> only when the job was
submitted unnamed. The name is set per submission path: the ?name= query (or a
name in the spec body) for POST /api/v1/jobs/spec and /api/v1/jobs, and the
per-statement name for SQL. The name also rides the retained graph and the HA
manifest, so it survives a leader takeover.
Enable it on the Coordinator:
clink_node --role=coordinator --http-port=8081 \
--lineage-listener=openlineage \
--lineage-endpoint=http://marquez:5000 \
--lineage-namespace=prod
--lineage-endpoint is http only (the built-in client is plain HTTP). The
capture side (the HTTP endpoint and the event stream) is always on; the listener
flag only wires an outbound exporter. Under HA only the leader submits jobs, so
there is no double-emit and no leadership gating is needed.
Scope¶
Lineage is dataset-level for every job and column-level for SQL jobs. Known limits:
- Lookup-join dimension columns. A
connector='lookup'dimension is an async function, not a source operator, so its columns are opaque: dimension-derived sink columns trace only as far as the probe side, not the dimension. - Window-synthetic columns.
window_start/window_endhave no upstream source column and carry no column-lineage entry. - Sub-expression precision. A computed expression records every leaf source
column it reads as an input with
transformation=TRANSFORMATION; it does not distinguish which operand of a nested expression each column feeds. - Cross-job dataset stitching. Correlating one job's sink topic with another
job's source topic is left to the external lineage system, which already does
this by
(namespace, name).
Source files¶
| File | Role |
|---|---|
include/clink/lineage/lineage_graph.hpp, src/lineage/lineage_graph.cpp |
The model, extract_lineage, connector_family, dataset_for / dataset_from_family, column-lineage types + JSON round-trip. |
include/clink/lineage/lineage_listener.hpp, src/lineage/lineage_listener.cpp |
LineageEvent, LineageListener, the registry, and the LineageDispatcher EventBus bridge. |
include/clink/lineage/openlineage_exporter.hpp, src/lineage/openlineage_exporter.cpp |
The built-in OpenLineage HTTP exporter (run events + columnLineage facet). |
include/clink/sql/column_lineage.hpp, src/sql/column_lineage.cpp |
The SQL column-lineage tracer (capture_column_lineage), run from PhysicalPlanner::compile. |
src/cluster/coordinator.cpp |
snapshot_job_lineage and the coordinator.job_lineage emit in submit_job. |
tools/clink_node.cpp |
The GET /api/v1/jobs/:id/lineage route and the dispatcher wiring (--lineage-* flags). |
tests/test_lineage.cpp, tests/test_column_lineage.cpp |
Unit tests for the model + dispatcher, and the SQL column-lineage tracer. |