15. ETL pipelines¶
Design only
This chapter describes functionality that is not in the current release. It is written as if it existed: apart from the diagnostics the current binary already gives, these programs do not run, and names, kinds, types and outputs may change before any of it ships. It is a story about where the pipeline of chapter 14 is going, told in one file.
Every data platform has a language for the wiring. Data Factory wires activities in JSON, PySpark in a DataFrame API, ksqlDB in SQL over topics, Kafka Streams in Java, dbt in SQL models with a dependency graph around them. Each describes the same four things: where data comes from, what is done to it, where it goes, and when. They differ in where a mistake is found: in a validator before deployment, in a cluster at run time, or in a dashboard the next morning.
HQL's claim is that the wiring is a program like any other in this book. A
pipeline is one .hql file; its sources and targets are ids that
configuration binds to systems; its types are checked before anything
connects; and the same text runs over a queue that never ends and over the
archive of what the queue once carried.
A pipeline is a file¶
A field station publishes bird sightings from its sensors to a Kafka topic.
A Postgres database holds the sites and receives daily counts. Alerts go to a
second topic, and everything read is archived as JSON Lines. The station's
hql.toml names each of those systems once:
[sources.sightings]
kind = "kafka"
brokers_env = "KAFKA_BROKERS"
topic = "sightings"
group = "station-nightly"
[sources.sites]
kind = "postgres"
dsn_env = "STATION_DSN"
table = "sites"
[sources.daily_counts]
kind = "postgres"
dsn_env = "STATION_DSN"
table = "daily_counts"
mode = "append"
[sources.alerts]
kind = "kafka"
brokers_env = "KAFKA_BROKERS"
topic = "alerts"
[sources.archive]
kind = "json_lines"
path = "archive/sightings.jsonl"
mode = "append"
The pipeline, nightly.hql, names only the ids:
type Sighting { species : String, site : String, seen : String, at : DateTime }
type Site { site : String, region : String, protected : Bool }
type Count { day : Date, region : String, species : String, sightings : Int }
sites : Seq<Site> = source("sites")
tap("sightings")
| Sighting
| tee archived = sink("archive")
| join(sites, on = (s, p) => s.site == p.site)
| map(j => {species: j.left.species, region: j.right.region, protected: j.right.protected, at: j.left.at})
| tee alerts =
filter(x => x.protected and x.species == "barn owl")
| map(x => {region: x.region, at: x.at, text: fmt("barn owl in protected {}", x.region)})
| sink("alerts")
| window(1d, by = x => x.at)
| map(w => w
| group(by = x => {region: x.region, species: x.species})
| map(g => Count(day = w.start.date, region = g.key.region, species = g.key.species, sightings = g.items | size)))
| emit
| sink("daily_counts")
Read top to bottom, it is the dataflow diagram a platform would draw: one
input, a lookup, two side outputs, a windowed aggregate, one main output.
There is no orchestration around it, no activity names, no cluster
configuration in the text. hql check nightly.hql reads hql.toml, resolves
every id to its kind and every kind to a type, checks the whole file, and
opens no connection.
Ids, not connections¶
source("sites") and tap("sightings") name configuration. The id is a
literal, because the type of what it reads is known only from the
configuration, and a program whose source could be computed would have a type
nobody could check. Three refusals exist today, on the
sources-by-id page of chapter 9: a computed id and a
connection string in place of an id are check errors, the second saying where
connection details belong, and an id the configuration does not declare is a
check error naming the nearest one.
A binding can break, and the design is that it breaks at the earliest phase that can see it. Checking sees configuration; evaluation sees the system:
| What is wrong | When it is found | What is said |
|---|---|---|
source("sightngs") |
checking | check error[unknown-name]: no source sightngs is configured; did you mean sightings? |
sites : Seq<Site> = source("sightings") |
checking | check error[type-mismatch]: sightings is a kafka source, read by tap as a Stream, not by source as a Seq |
source("sites") read as Seq<Count> |
checking | check error[type-mismatch]: the boundary of sites has no field day; Count declares one |
STATION_DSN not set |
evaluation, before the first row | execute failure[io-failure]: source sites reads its connection from $STATION_DSN, which is not set |
a row whose protected is "yes" |
evaluation, at that row | execute failure[io-failure]: source sites: row 14 does not decode as Site: field protected: "yes" is not a Bool |
| the broker unreachable | evaluation, before the first message | execute failure[io-failure]: source sightings: no broker at $KAFKA_BROKERS answered |
A secret is named by the environment variable that holds it and never printed. A failure at evaluation names the source by its id, the system by its configured name, and the row or message by its position, as a CSV decode error does today; what it never does is continue past a row it could not read and count it later as missing.
The configuration is also where a system's names meet the program's. The
sites table may call its region column region_code; the boundary of
chapter 9 maps it, [sources.sites.fields.region] name = "region_code", and
the program keeps the name it reads. Renaming a column is a change to one
line of configuration, not to every pipeline that reads it.
Streams in, tables out¶
tap("sightings") is a Stream<Data>, and | Sighting reads each message
as a declared record, so the rest of the file is typed. source("sites") is
a Seq<Site>: a table is finite, read once, replayable within the
evaluation. The join between them is the common shape of enrichment, a
stream against a lookup, and the type says why it is cheap: the finite side
is read whole, the live side passes through one message at a time, and no
message waits for another.
Two streams joined would wait for each other without end, and the checker
says so: join over two Streams is refused until one of them is bounded by
a window. A window is collect by time rather than by count,
window(1d, by = x => x.at), a Stream<Seq<T>> whose batches close when the
day does. Inside a batch every whole-input step of chapter 6 applies, which is
how the daily counts are a group and a size over an endless input.
emit lets the counts out one at a time, and sink("daily_counts") appends
each as a row.
The two tee branches write without ending the pipeline. The archive is
every sighting as it arrived, before enrichment; the alerts are a filter and
a map, written to a topic as messages. A tee branch is the fan-out a
platform draws as a diamond, and it is typed like everything else: a branch
that ended in target over the stream would be refused, because a stream may
never complete, and sink is what a stream is written with.
From a batch to a stream, and back¶
A pipeline written for a live topic is a pipeline written for its history,
because the steps are the same and the type is the only difference. The
archive is a file, so source("archive") is a Seq<Data>; stream turns a
finite sequence into a stream that produces its elements in order and then
ends, so the nightly pipeline replays a day from the archive with the topic
replaced and nothing else changed:
fn nightly(sightings: Stream<Sighting>) : Unit = …
source("archive") | Sighting | filter(s => s.at.date == Date(2026, 10, 24)) | stream | nightly
The other direction is as short. A stream becomes one file by bounding it
and writing the bound: take(n) for a count, window for a span,
collect(n) for batches of a size:
tap("sightings") | window(1h) | emit | sink(file("last-hour.jsonl"), Json)
source(file("examples/io/sightings.jsonl")) | take(2) | target(file("two.json"))
A backfill is the first program over the archive; a snapshot is the second over the topic; a replay for a test is either over a file of recorded messages, with no broker at all. The platform words, backfill, snapshot, replay, name three ways to bound or unbound one pipeline, not three pipelines.
Rows that do not decode¶
A pipeline over a queue meets messages that are not what the type says. The
default is the strict one above: the first such message stops the
evaluation, naming it. A pipeline that must keep running routes them
instead. Sighting? reads each message as a Result<Sighting, Invalid>,
and fork splits a stream of results into its two sides without ending
either:
tap("sightings")
| Sighting?
| fork
| tee rejected =
errs()
| map(e => {message: e.input, why: e.violations | map(v => v.message)})
| sink("dead_letter")
| oks()
| nightly
The dead-letter queue is a sink like the others, and what reaches it is a
value: the message as it arrived and every violation at once, the way
validation as a value
reports them. Nothing is logged and lost; the rejected rows are a source
another pipeline reads.
Partitions, schedules and backends¶
Three things a platform configures stay out of the file.
A partition is the logical unit of ownership: partition(by = x =>
x.region) states that values of one region belong together, so a windowed
group per region may run wherever that region's values are. It names no
worker and no Kafka partition; a backend maps the logical partitions onto its
own, and the local backend runs them one after another with the same result.
A schedule is configuration beside the sources, [jobs.nightly] run =
"nightly.hql" with an at, or a trigger that is itself a source, a topic
whose messages start a run. The file does not know when it runs.
A backend is the execution strategy of chapter 14: localhost in a
terminal, a distributed one in a cluster, chosen by [execution] backend or
--backend, and bound to compute what localhost computes. hql check is
the same under every backend, and it is what a deployment runs before it
deploys: the configuration, the types and the file, with no system touched.
What each of them wires¶
| Sources and sinks | Transformations | Where a mistake is found | |
|---|---|---|---|
| Data Factory | linked services and datasets, in JSON | activities, each a box | at validation of the JSON, or in the run's monitor |
| PySpark | readers and writers, in code | DataFrame operations | when the job runs, at the operation that fails |
| ksqlDB | topics as streams and tables | SQL over them | when the statement is submitted, or in the records it skips |
| dbt | models over a warehouse | SQL, with tests after | when the model builds, or when its tests run |
| HQL, as designed | ids bound in hql.toml |
the steps of this book | when the file is checked, before anything connects |
The columns are the same four things. What HQL adds is a type for each arrow, so that stream against table, finite against live, declared record against configured boundary, are checked where the file is written, and the file that passed is the file that runs.
What is next¶
Part III reads a different kind of data. Chapter 16 opens a folder of documents and asks it the questions chapter 6 asked a table.