Lab 1 — The machine
Verified on the supported cluster
- Stack: Flink 2.3.0 / PyFlink 2.3.0 / Kafka 4.3.1 / Docker Compose
- Platform: Docker Engine on Ubuntu 24.04 (x86_64)
- Date: 2026-09-12
- Source commit: 16f7a18
Four TaskManagers, eight slots, eight Kafka partitions, Python worker import boundary, occupied-TaskManager failure, and NoRestartBackoffTimeStrategy were observed on the live cluster.
Set up once — macOS or Linux
You need: Docker with Compose, Python 3.9 or newer, about 3 GB of RAM, and about 4 GB of image space. The labs run on your machine: one Flink JobManager, four TaskManagers, and one Kafka broker. No managed service or cloud account is used.
git clone https://github.com/SciMigo/flink-course-labs.git
cd flink-course-labs
docker compose up -d --build # first build installs PyFlink in the cluster image
docker compose ps
open http://localhost:8081 # keep the Flink Web UI open
Terminal path: follow the commands below. Local-page path: start agent-runtime, then serve the repository's lab pages. The runtime executes the same commands on your machine; it is not a hosted sandbox.
# terminal 2 — optional button runtime
curl -fsSL https://raw.githubusercontent.com/SciMigo/agent-runtime/main/scripts/install-macos.sh | bash
agent-runtime serve --port 9477
# terminal 3 — standard-library lab server
python3 lab_server.py # open http://localhost:3000
A loopback page may call the loopback runtime directly. A public course page must pair with the
runtime and receive an origin-bound bearer token; do not run a public lab with
--no-pairing.
You are going to run GatewayMeter for the first time and then take part of the cluster away from it.
The job is four lines of real work: read gateway-events, parse each record, count events per model in
a ten-second window, print. It bills nothing and remembers nothing. That is the point — by the end of
this lab you will have watched it lose everything it had, and you will know exactly why Flink let that
happen.
Start the cluster
docker compose up -d --build
docker compose ps
Five containers: one JobManager, four TaskManagers with two slots each, one Kafka broker. The first
build takes a few minutes — it installs Python and PyFlink into the Flink image, because a PyFlink job
needs an interpreter on every TaskManager that runs a Python operator, not just wherever you typed
flink run. The Web UI is on http://localhost:8081; leave it open in a tab for the whole course.
curl -s localhost:8081/overview
"taskmanagers": 4, "slots-total": 8. Those eight slots are the capacity of your cluster. The job asks
for four.
Send it some traffic
common/produce.py is a synthetic gateway: it prints one JSON event per line and writes nothing to
Kafka itself, so you can read the stream before Flink ever sees it.
python3 common/produce.py --rate 20 --seconds 1 | head -3
Each line is request_id <TAB> {json}. The key matters: the gateway keys by request_id, so one
request's events share a partition. Now point it at the broker and leave it running:
python3 common/produce.py --rate 20 \
| docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:19092 --topic gateway-events \
--property parse.key=true --property key.separator=$'\t'
In a second terminal, confirm the events are landing across all eight partitions:
docker compose exec -T kafka /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server localhost:19092 --topic gateway-events
Submit the job
docker compose exec -T jobmanager flink run -d -py /labs/01-the-machine/gatewaymeter/job.py
-d detaches: the client submits the JobGraph and leaves. The job now belongs to the cluster, not to
your shell — which is why closing this terminal does not stop it, and why print() does not appear
here. It appears in the TaskManager that ran the operator:
docker compose logs -f taskmanager | grep -E '\((gpt|claude|gemini)'
4> (claude-opus-5,161)
1> (gpt-5.4-mini,125)
1> (gemini-3.6-flash,157)
2> (gpt-5.5,195)
The 4> and 1> are subtask numbers. Four subtasks, because env.set_parallelism(4); each one owns a
share of the models, because key_by decides which subtask sees which key. Open the Web UI and look at
the job graph — the source and the map are chained into one task, the window is a second task, and the
edge between them is a hash shuffle. Nothing about that was your choice except the key_by.
Now kill a TaskManager
Find which TaskManagers actually run your job. In the Web UI, click the job, then a task, then
Subtasks — each row names its host. This matters: the cluster has eight slots and the job uses four,
so a TaskManager chosen at random may be running none of your subtasks, and killing it proves
nothing. (Measured: killing one of four TaskManagers took the cluster from 8 slots to 6 and the job
stayed RUNNING throughout.)
Kill one that is running a subtask:
docker kill <the container from the Subtasks tab>
docker compose logs --tail 30 jobmanager
curl -s localhost:8081/jobs/overview
The job goes to FAILED, and the JobManager says why:
org.apache.flink.runtime.JobException: Recovery is suppressed by NoRestartBackoffTimeStrategy
Read that sentence carefully, because it is the whole lesson of this module. Flink did not try to recover and lose your counts. Flink never tried at all. A job with no checkpointing configured gets no restart strategy by default, so the first lost TaskManager is terminal. The counts in the windows that had not fired are gone; the Kafka offsets the source had consumed but not committed are gone with them. Nothing was double-counted, because nothing was counted twice — nothing was counted at all.
Restore the cluster and move on:
docker compose up -d taskmanager
If a TaskManager dies right now
Which numbers survive, which are recomputed, and which could be emitted twice?
At this stage: none survive, none are recomputed, none are emitted twice. The window contents live in the operator's memory and die with the process. The job does not restart, so there is no second emission to worry about. Every module from here on makes this answer longer and more interesting; module 7 is where it stops being embarrassing.
Exercises
1.1 Submit the job a second time while the first is still running. Both run. What does
slots-available say now, and what happens when you submit a third? Explain the number you get in terms
of slots, not jobs.
1.2 Change WINDOW_SECONDS to 60 and resubmit. The first output takes up to a minute to appear.
Now stop the traffic and watch: the window never fires again. Why is the last, partial window not
emitted when the data stops? (You are meeting processing time. Module 4 is the fix.)
1.3 Remove env.add_python_file(...) — or the PYTHONPATH from docker-compose.yml — and resubmit.
The job submits cleanly and then dies with ModuleNotFoundError: No module named 'common' from inside
the SDK harness. Explain where your Python actually runs. (Measured: this is the most common way a
first PyFlink job fails.)
1.4 With the job running, docker kill the Kafka container instead of a TaskManager. Does the
job fail? How long does it take to notice, and what does the source do when the broker comes back?
Contrast that with the TaskManager kill: one of these is a Flink failure and one is not.
Files in this lab
gatewaymeter/job.py— stage 1 of the job every module grows.../common/events.py— the event envelope andparse_event, shared by every lab.../common/produce.py— the synthetic gateway.tests/test_events.py— the envelope round-trips; run withpython3 -m pytest 01-the-machine/tests.
Files
Every relevant file is shown here; the repository remains the source of truth.
01-the-machine/gatewaymeter/__init__.py
01-the-machine/gatewaymeter/job.py
"""GatewayMeter, stage 1: how many events per model, every ten seconds. This is the whole job for module 1 — a source, a parse, a key, a window, a print. It bills nothing and remembers nothing across a restart, on purpose: checkpointing is **off by default** in Flink, and module 7 is where we turn it on and earn the right to make a durability claim. Run it from the JobManager container: docker compose exec jobmanager flink run -py /labs/01-the-machine/gatewaymeter/job.py Then watch http://localhost:8081 and the TaskManager logs (`docker compose logs -f taskmanager`), which is where `print()` lands — a Flink job's stdout is the cluster's, not your shell's. """ from __future__ import annotations import sys sys.path.insert(0, "/labs") from pyflink.common import Time, Types, WatermarkStrategy # noqa: E402 from pyflink.common.serialization import SimpleStringSchema # noqa: E402 from pyflink.datastream import StreamExecutionEnvironment # noqa: E402 from pyflink.datastream.connectors.kafka import ( # noqa: E402 KafkaOffsetsInitializer, KafkaSource, ) from pyflink.datastream.window import TumblingProcessingTimeWindows # noqa: E402 from common.events import parse_event # noqa: E402 BOOTSTRAP = "kafka:19092" TOPIC = "gateway-events" WINDOW_SECONDS = 10 def model_and_one(raw: str): """One JSON line -> (model, 1). `parse_event` is the shared envelope; the count is this module's.""" return parse_event(raw).model, 1 def build_job(env: StreamExecutionEnvironment): """Wire the job. Every module in this course adds to this function and nothing else.""" source = ( KafkaSource.builder() .set_bootstrap_servers(BOOTSTRAP) .set_topics(TOPIC) .set_group_id("gatewaymeter-m01") # Read only what arrives from now on. `earliest()` would replay the whole topic every # time you submit — useful in module 7, confusing here. .set_starting_offsets(KafkaOffsetsInitializer.latest()) .set_value_only_deserializer(SimpleStringSchema()) .build() ) # no_watermarks(): this stage has no notion of event time yet. The records carry a `ts` and the # job ignores it — module 4 is where that stops being acceptable. events = env.from_source(source, WatermarkStrategy.no_watermarks(), "gateway-events") counts = ( events .map(model_and_one, output_type=Types.TUPLE([Types.STRING(), Types.INT()])) .key_by(lambda t: t[0], key_type=Types.STRING()) .window(TumblingProcessingTimeWindows.of(Time.seconds(WINDOW_SECONDS))) .reduce(lambda a, b: (a[0], a[1] + b[1])) ) counts.print() return counts def main() -> None: env = StreamExecutionEnvironment.get_execution_environment() env.set_parallelism(4) # A Python UDF does not run in this process. It runs in a Python worker beside each TaskManager, # and that worker only imports what the job shipped to it — so `common` has to be declared, not # merely importable here. Forget this and the job submits cleanly, then dies on the first record # with ModuleNotFoundError deep inside the SDK harness. env.add_python_file("/labs/common") build_job(env) env.execute("GatewayMeter - module 1 - events per model") if __name__ == "__main__": main()
01-the-machine/tests/test_events.py
"""The envelope is the one thing every lab depends on, so it gets a test that needs no cluster.""" import json import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) from common.events import EVENT_TYPES, REQUEST_COMPLETED, Event, parse_event # noqa: E402 def test_round_trip(): e = Event(REQUEST_COMPLETED, "r-1", "acme", "gpt-5.5", 1789000000000, {"status": "ok", "completion_tokens": 207, "latency_ms": 486}) assert parse_event(e.to_json()) == e def test_ts_is_an_int_even_when_the_gateway_sends_a_string(): raw = json.dumps({"event_type": "ModelError", "request_id": "r-2", "tenant_id": "globex", "model": "gpt-5.5", "ts": "1789000000000", "payload": {"code": 429}}) assert parse_event(raw).ts == 1789000000000 def test_missing_payload_is_empty_not_none(): raw = json.dumps({"event_type": "RequestStarted", "request_id": "r-3", "tenant_id": "hooli", "model": "gpt-5.5", "ts": 1}) assert parse_event(raw).payload == {} def test_producer_emits_only_known_event_types(): import subprocess root = pathlib.Path(__file__).resolve().parents[2] out = subprocess.run([sys.executable, str(root / "common/produce.py"), "--rate", "50", "--seconds", "1", "--seed", "1"], capture_output=True, text=True, timeout=60) lines = [l for l in out.stdout.splitlines() if l.strip()] assert lines, out.stderr for line in lines: key, _, raw = line.partition("\t") ev = parse_event(raw) assert ev.event_type in EVENT_TYPES assert ev.request_id == key # the Kafka key is the request id: one request, one partition
common/events.py
"""The event envelope every lab shares. The gateway emits one JSON object per thing that happens to a request. `ts` is **event time** — the millisecond the gateway says the thing happened — and it is the only clock the job may bill by. Kafka's append time is not it, and neither is the moment an operator sees the record. """ from __future__ import annotations import json from dataclasses import dataclass, field from typing import Any, Dict # The six things the gateway reports. Frozen: a lab that needs a new fact adds a payload field, # never a seventh type. REQUEST_STARTED = "RequestStarted" # payload: prompt_tokens TOKEN_GENERATED = "TokenGenerated" # payload: n TOOL_CALLED = "ToolCalled" # payload: tool, latency_ms AGENT_STEP_COMPLETED = "AgentStepCompleted" # payload: step, tokens_in, tokens_out MODEL_ERROR = "ModelError" # payload: code REQUEST_COMPLETED = "RequestCompleted" # payload: status, completion_tokens, latency_ms EVENT_TYPES = ( REQUEST_STARTED, TOKEN_GENERATED, TOOL_CALLED, AGENT_STEP_COMPLETED, MODEL_ERROR, REQUEST_COMPLETED, ) @dataclass class Event: event_type: str request_id: str tenant_id: str model: str ts: int # event time, ms since epoch payload: Dict[str, Any] = field(default_factory=dict) def to_json(self) -> str: return json.dumps({ "event_type": self.event_type, "request_id": self.request_id, "tenant_id": self.tenant_id, "model": self.model, "ts": self.ts, "payload": self.payload, }) def parse_event(raw: str) -> Event: """JSON line -> Event. A plain map; no state, no side effects, no clock. Malformed records raise. Module 2 is where the job decides what to do about that; until then a bad record should stop you, not vanish. """ d = json.loads(raw) return Event( event_type=d["event_type"], request_id=d["request_id"], tenant_id=d["tenant_id"], model=d["model"], ts=int(d["ts"]), payload=d.get("payload") or {}, )
Lab source: 01-the-machine in SciMigo/flink-course-labs. Target stack: Flink 2.3.0, PyFlink 2.3.0, Kafka 4.3.1. Validate live-cluster observations on the supported compose stack before publication.