14. Pipelines¶
Part I was about the steps: what each one takes, what it gives, and the type that joins them. This part is about the pipeline the steps form, and the systems a pipeline connects. This chapter is the frame; chapter 15 is the story it frames, an ETL pipeline written as one file.
A pipeline language¶
An HQL program is values flowing through typed steps. | is the operator
that forms a step: the value on its left becomes the input of the
operation on its right, and the result is the input of the next. A step
invokes exactly one operation, filter, sort, map, or one
presentation, json, markdown:
[3, 1, 2] | filter(n => n > 1) | sort | map(n => n * 10)
[20, 30]
A dot is not a step. s.trim() is a receiver call, and b.title a field
read; both are expression evaluation inside the step that holds them, and
neither is a position in the pipeline:
["Dune ", " Emma"] | map(s => s.trim().upper())
["DUNE", "EMMA"]
The two operators answer two questions. | says what happens to the whole
collection, one operation at a time, in the order written. . says what is
computed from one value, inside one of those operations. A reader finds the
shape of a program in its pipes, and the detail of each stage in its dots.
Three phases¶
A program is parsed, then checked, then executed, and a diagnostic is headed
by the phase that wrote it: parse failure and check error before anything
runs, execute failure after (chapter 12). Checking resolves every
name, import and module, infers and checks every type, and verifies every
trait implementation; a program the checker rejects has no meaning, and no
step of it executes. hql check runs the first two phases alone and prints
the type the third would produce:
[3, 1, 2] | filter(n => n > 1) | sort | map(n => n * 10)
List<Int>
Execution is a strategy a backend chooses. localhost, the one backend
the current release has, evaluates the checked program step by step, each
step run to completion before the next, in memory. A backend that compiles a
program, or runs it over many machines, is bound to the same contract: it
accepts exactly what the checker accepts, and it computes the value
localhost computes, at the same type, with the same failure where
localhost fails. There is no syntax for choosing a backend, because a
program's answer does not depend on it; the choice is configuration, and
hql config reports it.
The unit of execution¶
A step is the unit of execution. It runs over its whole input, and a backend may not break it, reorder it against an effect, or run it twice. Two rules follow.
An effectful step, one that reaches outside the program, source,
target, sink, a logger's info, runs once, where it is written, in
program order. A backend may plan how the pure steps between two effects are
fused or distributed; it may not move an effect, and a backend that cannot
run a program refuses it before running any part of it.
A pure step may be rewritten by its shape. A step that decides each value
alone, filter, map, may run over any part of the input in any order; a
step that needs the whole input, sort, group, sum, size, is where the
parts meet, and the pipeline holds nothing in between. The reading list of
chapter 16 is three such steps: filter decides each card on its own, sort
needs every card before it can place the first, and map turns each card into
its title. That alternation of per-value steps and whole-input steps is the
shape of MapReduce, and HQL keeps it visible in the pipeline: nothing is
stored between steps, and nothing is a loop. collect(n) and emit change
the shape of what flows, batches of values or their members, and tee
branches a pipeline without ending it; each of them is a step like any other,
and the type says what the next step may ask.
The command line¶
The smallest pipeline is a shell pipe. hql '<program>' reads standard input
with source(stdin, Json), writes its result as jq or awk would, and
names no input or output of its own: input is source or tap in the
program, output is target or sink in the program, or the result, presented
by --to. The command-line page of chapter 9 shows
each flag; one program there reads a log as it is written, appends its errors
to a file and prints every message, with no orchestration around it. The
query is the dataflow, and the shell is the simplest backend of all.
Connecting systems¶
Design only
This section describes functionality that is not in the current release. It is written as if it existed: these programs do not run against the current binary, and names, types and outputs may change before they ship.
The systems a data platform joins, queues, databases, services, scheduled
jobs, are endpoints and configured sources to a pipeline. Each is reached by
source, tap, target and sink, with the type saying whether it ends.
Configured sources keep connection details out of programs. A program
names an id; hql.toml names the system, and a secret is read from the
environment the configuration names, never written in either:
[sources.sightings]
kind = "postgres"
dsn_env = "BIRDS_DSN"
table = "sightings"
type Sighting { species : String, site : String, seen : String }
sightings : Seq<Sighting> = source("sightings")
sightings | group(by = s => s.species) | map(g => {species: g.key, seen: g.items | size})
A relational table is a Seq of rows read through the schema boundary of
chapter 9; a write by id is target("sightings"), in the mode the
configuration states. A connection string in a program is a type error today,
and stays one.
Messaging systems such as Kafka and NATS are live endpoints. A topic is
read by tap, as a Stream, and written by sink, one document per value;
collect(n) batches what is written, and partition(by = key) states which
values belong together, a logical unit a backend may map onto the system's
own partitions without the program naming them:
[sources.events]
kind = "kafka"
brokers_env = "KAFKA_BROKERS"
topic = "sightings"
tap("events")
| filter(e => e.seen == "night")
| partition(by = e => e.site)
| sink("alerts")
Network endpoints, http, websocket and socket under import net,
are in the current release, on the network page. An HTTP
resource read once is a Seq; a socket is a Stream.
A pipeline file is a job. What a data platform calls a job, a notebook or
a workflow, sources, transformations, sinks and a schedule, is an .hql file
whose steps are the transformations and whose source and sink steps are
its edges; the schedule, the credentials and the backend are configuration
around it, and hql check reads the configuration and opens nothing. The
same file runs on localhost from a terminal and, as design direction, on a
backend that distributes its pure steps, with the same answer.
What is next¶
Chapter 15 puts the frame to work: a pipeline that reads a queue, enriches what it reads from a database, and writes tables, alerts and an archive, in one checked file.