Lab 1 — The machine

Verified on the supported cluster

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

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.