Lab 12 — GatewayMeter under fire
Draft lab — not yet verified on a cluster
These steps have not been run end to end on the compose cluster. Commands, timings and outputs may be wrong, and the lab code may fail to submit. If something does not work as written, that is this page, not you — check SciMigo/flink-course-labs for the current state.
Run it locally
These exercises need Docker, Kafka, and a real Flink cluster. From the labs repository:
docker compose up -d
open http://localhost:8081
# optional local buttons
agent-runtime serve --port 9477 # another terminal
python3 lab_server.py # then http://localhost:3000/lab/12-capstone-gatewaymeter
The terminal commands remain the source of truth. Agent Runtime only executes them locally on your behalf.
Goal: assemble the exact stages already tested, write the failure contract before running them, and defend every guarantee with an artifact.
12.1 Write the contract first
For each frozen UID, fill in: key, state and bound, clock, late-data policy, checkpoint restore, possible repeated output, external deduplication owner, and deploy compatibility. Do this from memory, then compare with the capstone reading.
12.2 Fix the incident in advance
python3 -m pytest 12-capstone-gatewaymeter/tests -q
python3 common/reset_tenant_cost.py --with-input
mkdir -p evidence
python3 common/produce.py --seed 42 --start-ms 1767225600000 --rate 20 --requests 4800 \
--hot-tenant acme --hot-tenant-pct 0.40 --late-pct 0.05 --late-seconds 60 --abandon-pct 0.01 \
--stall-partition 5 --stall-after-seconds 20 --wake-after-seconds 110 > evidence/trace.jsonl
python3 common/invoice_oracle.py evidence/trace.jsonl --through-ms 1767225780000 \
> evidence/oracle.jsonl
shasum -a 256 evidence/trace.jsonl evidence/oracle.jsonl > evidence/sha256.txt
Cancel every earlier lab job and stop every producer you left running for an earlier lab. The reset
refuses while Flink reports an active job, and fails if gateway-events is still receiving records
five seconds after it was emptied. --with-input empties gateway-events as well as tenant-cost:
every stage reads its input from the earliest offset, so without it stage 12 would also bill the
traffic of Labs 1–11, and a stray producer would mix today's timestamps into the trace.
The trace is the whole incident, written before anything runs. It covers four minutes of event time
from 00:00:00 UTC on 2026-01-01: 20 requests/s, acme at 40%, 5% of requests delivered 60 s late,
1% abandoned. The origin is in the past on purpose: the sink writes each result with its event time as
the Kafka record timestamp, and the broker rejects timestamps more than an hour ahead
(log.message.timestamp.after.max.ms). Partition 5's gateway node goes silent at T+20, buffers, and delivers what it held at
T+110 with the original timestamps. The seed and origin make the file byte-for-byte reproducible:
its SHA-256 is 4c7afe4015dd6c711c456680e3b3c7dcfd70fe46727eb8f3da047fb20be34f2e on any machine.
The oracle runs the trace through the job's own semantics (assembly, abandonment timer, price book, one-minute event-time windows) and keeps the four minutes that end by 00:03:00. The last minute of traffic is the tail: it moves the watermark past 00:03:00 so that minute can close, and stays out of the review.
Submit stage 12 against a billing service that takes two seconds per lookup for the whole run:
docker compose exec -T jobmanager env GM_PRICE_DELAY_SECONDS=2 GM_RESTART_ATTEMPTS=10 \
flink run -d -py /labs/12-capstone-gatewaymeter/gatewaymeter/job.py
Module 7's job allows three restarts. Each TaskManager death here can cost two: the restart can land before the JobManager has noticed the dead TaskManager (up to the 50 s heartbeat, module 1), and that failed deployment is a failure too. Ten attempts cover both deaths with room to spare.
12.3 Run the incident
When the job is RUNNING, replay the trace. It goes out at its event-time pace, so T+20 is 20 s
into the replay, and the replay prints its T+ marks:
python3 common/replay.py evidence/trace.jsonl |
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'
The trace carries the input failures. You add the two TaskManager deaths:
| When | What happens | Who |
|---|---|---|
| T+00 | traffic begins; every price lookup takes 2 s | trace, job config |
| T+20 | partition 5 goes silent; the watermark freezes | trace |
| ~T+45 | idleness excludes partition 5; the watermark moves again | Flink |
| ~T+85 | the minute ending 00:01:00 fires, short of partition 5's requests from 00:00:20 on | Flink |
| T+110 | partition 5 delivers 90 s of held requests stamped T+20–T+110; the minute ending 00:01:00 fires again, corrected | trace |
| T+125 | docker kill an occupied TaskManager about 5 s after a checkpoint completes, then bring it back: docker compose up -d taskmanager |
you |
| ~T+160 | the minute ending 00:02:00 fires; kill a second occupied TaskManager before the next checkpoint completes, and bring it back the same way | you |
| ~T+220 | the minute ending 00:03:00 fires | Flink |
| T+240 | the replay ends; the tail's minute stays open, outside the review | trace |
The watermark trails the replay by the 20 s bound plus the pipeline's own delay (5–15 s on the course
cluster: two-second lookups and Python bundles), so a minute fires 25–35 s after it ends. On the course
cluster (2026-09-19) the timeline ran as above: each kill cost two restarts, and invoice_diff.py
matched all 24 minutes to the micro-dollar with no repeated value.
From stage 4 the job reads each of the eight partitions with its own source subtask, so it fills all
eight slots and cannot restart until the dead TaskManager is back. In production the platform restarts
it (the reading's "Where Flink ends"); here you do. Restarts move the "~" rows: watch the
tenant-cost vertex's records sent rather than the clock. A second TaskManager sometimes dies with
the one you kill: PyFlink's state access can crash RocksDB while tasks are cancelled (a SIGSEGV in
createColumnFamily on the course cluster). docker compose up -d taskmanager restarts every
stopped TaskManager, so the step is the same.
At each row capture job status, numRestarts, input watermarks, the last window_end visible at
read_committed, busy/backpressured time, Kafka lag, and checkpoint duration/size. Save the REST
responses as checkpoints.json, vertices.json, and metrics.json.
12.4 Diff and defend
Once the minute ending 00:03:00 is visible at read_committed, read the whole topic and diff it:
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:19092 --topic tenant-cost --from-beginning \
--consumer-property isolation.level=read_committed --timeout-ms 15000 > evidence/output.jsonl
python3 common/invoice_diff.py evidence/output.jsonl evidence/oracle.jsonl > evidence/diff.json
invoice_diff.py materializes each (tenant_id, window_end) the way the billing store's upsert
does and compares it with the oracle to the micro-dollar. corrected_minutes counts minutes emitted
with more than one value: late corrections, which the upsert absorbs. repeated_values counts the
same value emitted twice: a recovery duplicate, which a read_committed consumer should never see.
Classify every mismatch as recovery duplicate, late correction, too-late adjustment, price fallback,
or bug.
Answer the ten defense questions from the reading aloud. Every answer must name the operator or external system that owns the guarantee and the evidence you captured.
Keep the final run in evidence/: trace.jsonl, oracle.jsonl, sha256.txt, checkpoints.json,
vertices.json, metrics.json, output.jsonl, and diff.json.
Stretch: falsify one guarantee
Choose one: unstable transactional prefix, Python hash() instead of CRC32, missing UID, TTL shorter
than a watermark stall, append-and-sum billing consumer, or insufficient spare slots. Predict the
failure, introduce it, and show the prediction in evidence.
If a TaskManager dies right now
Checkpoint k, source positions, keyed/window/timer state, partial replacements, and committed Kafka
transactions survive. Records after S_k are replayed; async reads can be re-issued. A
read_committed cost consumer sees no recovery duplicate. Late firings remain real corrections and
the billing store must upsert them. Alerts and latency outputs may be at least once and require their
own consumer deduplication.
Done when
invoice_diff.py exits 0, or every mismatch it reports is explained to the micro-dollar; the
contract matches the implemented graph; and another engineer can regenerate your trace from the seed,
match its hash, and rerun the incident.
Files
Every relevant file is shown here; the repository remains the source of truth.
12-capstone-gatewaymeter/gatewaymeter/__init__.py
"""GatewayMeter stage 12."""
12-capstone-gatewaymeter/gatewaymeter/job.py
"""GatewayMeter stage 12. See this lab's README and common/flink_job.py.""" from __future__ import annotations import sys sys.path.insert(0, "/labs") from common.flink_job import build_stage, run_stage # noqa: E402 def build_job(env): return build_stage(env, 12) if __name__ == "__main__": run_stage(12)
12-capstone-gatewaymeter/tests/test_lab_12.py
import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) import hashlib import json import subprocess import pytest from common.events import Event, REQUEST_COMPLETED, REQUEST_STARTED, TOKEN_GENERATED from common.gatewaymeter import aggregate_costs, assemble, merge_partials, partial_costs from common.invoice_diff import diff from common.invoice_oracle import materialize from common.produce import kafka_partition_for_key from common.replay import schedule ROOT = pathlib.Path(__file__).resolve().parents[2] ORIGIN = 1_767_225_600_000 def test_full_invoice_matches_two_stage_aggregation(): events = [] for i in range(20): events += [ Event(REQUEST_STARTED, f"r{i}", "acme" if i < 15 else "globex", "gpt-5.5", i, {"prompt_tokens": 100}), Event(REQUEST_COMPLETED, f"r{i}", "acme" if i < 15 else "globex", "gpt-5.5", 1000+i, {"completion_tokens": 20}), ] records, open_requests = assemble(events) assert open_requests == {} assert merge_partials(partial_costs(records)) == aggregate_costs(records) def keyed(*events): return [f"{event.request_id}\t{event.to_json()}" for event in events] def produce(*flags): out = subprocess.run( [sys.executable, str(ROOT / "common/produce.py"), "--seed", "7", "--start-ms", str(ORIGIN), "--rate", "20", *flags], capture_output=True, text=True, timeout=30, check=True, ) return out.stdout.splitlines() def test_oracle_prices_like_the_stage_12_job(): lines = keyed( Event(REQUEST_STARTED, "r1", "acme", "gpt-5.5", 1, {"prompt_tokens": 100}), Event(REQUEST_COMPLETED, "r1", "acme", "gpt-5.5", 1001, {"completion_tokens": 20}), Event(REQUEST_STARTED, "r2", "globex", "gpt-5.5", 1, {"prompt_tokens": 100}), Event(REQUEST_COMPLETED, "r2", "globex", "gpt-5.5", 1001, {"completion_tokens": 20}), ) # 1,920 micro-dollars at list price; acme's negotiated 10% discount comes from the same price # book the job's price lookup uses from stage 9 on. assert materialize(lines) == [ {"tenant_id": "acme", "window_end": 60000, "micro_usd": 1728, "requests": 1}, {"tenant_id": "globex", "window_end": 60000, "micro_usd": 1920, "requests": 1}, ] def test_oracle_bills_an_abandoned_request_when_its_timer_fires(): lines = keyed( Event(REQUEST_STARTED, "r1", "globex", "gpt-5.5", 1_000, {"prompt_tokens": 100}), Event(TOKEN_GENERATED, "r1", "globex", "gpt-5.5", 1_010, {"n": 20}), ) # The assembler's timer fires at start + 5 minutes and bills the tokens seen so far. assert materialize(lines) == [ {"tenant_id": "globex", "window_end": 6 * 60_000, "micro_usd": 1920, "requests": 1} ] def test_oracle_refuses_a_horizon_the_trace_cannot_close(): # One minute of event time; a tenth of the requests arrive 30 s late. lines = produce("--requests", "1200", "--late-pct", "0.1") with pytest.raises(ValueError, match="cannot close"): materialize(lines, through_ms=ORIGIN + 60_000) # The minute before the origin holds the first late requests, and the trace can close it. closed = materialize(lines, through_ms=ORIGIN) assert closed and all(row["window_end"] == ORIGIN for row in closed) def test_a_trace_is_reproducible_and_needs_no_clock(): assert produce("--requests", "300") == produce("--requests", "300") def test_a_waking_partition_delivers_its_held_requests_late_with_their_timestamps(): plain = produce("--requests", "400") held = produce("--requests", "400", "--stall-partition", "5", "--stall-after-seconds", "4", "--wake-after-seconds", "12") # Same requests, same events: only the delivery order changes, so the invoice cannot. assert sorted(held) == sorted(plain) assert held != plain assert materialize(held) == materialize(plain) starts = [json.loads(line.split("\t", 1)[1]) for line in held] starts = [event for event in starts if event["event_type"] == "RequestStarted"] seconds = [(event["ts"] - ORIGIN) / 1000 for event in starts] partition = [kafka_partition_for_key(event["request_id"]) for event in starts] wake = seconds.index(12.0) released = [i for i, (sec, p) in enumerate(zip(seconds, partition)) if p == 5 and 4 <= sec < 12] # Partition 5's 4-12 s requests arrive together at 12 s, after everything else before 12 s. assert released == list(range(wake - len(released), wake)) assert all(i < released[0] for i, sec in enumerate(seconds) if sec < 12 and i not in released) def test_replay_paces_by_the_newest_request_start(): lines = keyed( Event(REQUEST_STARTED, f"r-{ORIGIN}-0000001", "acme", "gpt-5.5", ORIGIN, {}), Event(REQUEST_COMPLETED, f"r-{ORIGIN}-0000001", "acme", "gpt-5.5", ORIGIN + 3_000, {}), Event(REQUEST_STARTED, f"r-{ORIGIN}-0000002", "acme", "gpt-5.5", ORIGIN + 1_000, {}), # Late, or held back by a stalled partition: old timestamps do not move the pace. Event(REQUEST_STARTED, f"r-{ORIGIN}-0000003", "acme", "gpt-5.5", ORIGIN - 30_000, {}), Event(REQUEST_STARTED, f"r-{ORIGIN}-0000004", "acme", "gpt-5.5", ORIGIN + 2_500, {}), ) assert [at for at, _ in schedule(lines)] == [0.0, 0.0, 1.0, 1.0, 2.5] def row(tenant, minute, requests, micro): return json.dumps({"tenant_id": tenant, "window_end": ORIGIN + minute * 60_000, "requests": requests, "micro_usd": micro}) def test_diff_materializes_corrections_whatever_order_kafka_returns_them_in(): oracle = [row("acme", 1, 10, 900), row("globex", 1, 5, 500)] output = [ row("acme", 1, 10, 900), # the correction, read first from another partition row("acme", 1, 8, 700), # the first firing row("globex", 1, 5, 500), row("globex", 2, 1, 100), # the trace's tail, past the oracle's horizon ] report = diff(output, oracle) assert report["ok"] and report["matched"] == 2 assert report["emissions"] == {"total": 3, "corrected_minutes": 1, "repeated_values": 0} def test_diff_reports_what_does_not_match(): oracle = [row("acme", 1, 10, 900), row("globex", 1, 5, 500)] output = [row("acme", 1, 10, 1000), row("acme", 1, 10, 900), row("initech", 1, 1, 1)] report = diff(output, oracle) assert not report["ok"] assert [c["tenant_id"] for c in report["conflicts"]] == ["acme"] assert [m["tenant_id"] for m in report["missing"]] == ["globex"] assert [u["tenant_id"] for u in report["unexpected"]] == ["initech"] def test_the_capstone_trace_is_the_one_the_readme_describes(): readme = (ROOT / "12-capstone-gatewaymeter/README.md").read_text(encoding="utf-8") command = readme.split("python3 common/produce.py ", 1)[1].split(" > evidence/trace.jsonl", 1)[0] flags = command.replace("\\\n", " ").split() out = subprocess.run([sys.executable, str(ROOT / "common/produce.py"), *flags], capture_output=True, check=True, timeout=30).stdout assert hashlib.sha256(out).hexdigest() in readme
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 {}, )
common/gatewaymeter.py
"""Pure GatewayMeter business semantics shared by the labs and their fast tests. Nothing in this module imports Flink. A lab can therefore distinguish two questions: * is the invoice calculation correct for this event trace? and * does Flink preserve that calculation under partitioning, time, and failure? The first question belongs in a fast unit test. The second belongs in the compose cluster. Money is represented as integer micro-dollars so replay and partial aggregation are deterministic. """ from __future__ import annotations from dataclasses import dataclass, field, replace from typing import Iterable, Mapping, Sequence import zlib from common.events import REQUEST_COMPLETED, REQUEST_STARTED, TOKEN_GENERATED, Event MINUTE_MS = 60_000 PRICE_PER_1K = { "gpt-5.5": (12_000, 36_000), "gpt-5.4-mini": (1_000, 4_000), "claude-opus-5": (15_000, 75_000), "gemini-3.6-flash": (500, 2_000), } @dataclass class OpenRequest: request_id: str tenant_id: str model: str started_ts: int prompt_tokens: int completion_tokens: int = 0 @dataclass(frozen=True) class RequestRecord: request_id: str tenant_id: str model: str started_ts: int completed_ts: int latency_ms: int prompt_tokens: int completion_tokens: int status: str price_multiplier_ppm: int = 1_000_000 price_source: str = "list" @dataclass(frozen=True) class TenantCostMinute: tenant_id: str window_end: int micro_usd: int requests: int def key(self) -> tuple[str, int]: return self.tenant_id, self.window_end def to_dict(self) -> dict[str, int | str]: return { "tenant_id": self.tenant_id, "window_end": self.window_end, "micro_usd": self.micro_usd, "requests": self.requests, } @dataclass(frozen=True) class PartialCost: tenant_id: str salt: int window_end: int micro_usd: int requests: int @dataclass class RequestAssemblerModel: """A small deterministic oracle for the keyed RequestAssembler operator.""" open_requests: dict[str, OpenRequest] = field(default_factory=dict) def apply(self, event: Event) -> RequestRecord | None: if event.event_type == REQUEST_STARTED: self.open_requests[event.request_id] = OpenRequest( event.request_id, event.tenant_id, event.model, event.ts, int(event.payload.get("prompt_tokens", 0)), ) return None current = self.open_requests.get(event.request_id) if current is None: return None if event.event_type == TOKEN_GENERATED: current.completion_tokens += int(event.payload.get("n", 0)) return None if event.event_type != REQUEST_COMPLETED: return None self.open_requests.pop(event.request_id, None) return RequestRecord( request_id=current.request_id, tenant_id=current.tenant_id, model=current.model, started_ts=current.started_ts, completed_ts=event.ts, latency_ms=int(event.payload.get("latency_ms", event.ts - current.started_ts)), prompt_tokens=current.prompt_tokens, completion_tokens=int(event.payload.get("completion_tokens", current.completion_tokens)), status=str(event.payload.get("status", "ok")), ) # From stage 6 the assembler sets an event-time timer at start + 5 minutes; a request still open # when it fires is billed as abandoned, for the tokens seen so far. ABANDON_AFTER_MS = 5 * MINUTE_MS def abandoned_record(request: OpenRequest, fired_at: int) -> RequestRecord: """The record the assembler's timer emits for a request that never completed.""" return RequestRecord( request_id=request.request_id, tenant_id=request.tenant_id, model=request.model, started_ts=request.started_ts, completed_ts=fired_at, latency_ms=fired_at - request.started_ts, prompt_tokens=request.prompt_tokens, completion_tokens=request.completion_tokens, status="abandoned", ) def assemble(events: Iterable[Event]) -> tuple[list[RequestRecord], dict[str, OpenRequest]]: model = RequestAssemblerModel() records: list[RequestRecord] = [] for event in events: record = model.apply(event) if record is not None: records.append(record) return records, model.open_requests def request_cost_micro_usd( record: RequestRecord, prices: Mapping[str, tuple[int, int]] = PRICE_PER_1K, ) -> int: """Return deterministic micro-dollars, rounded once at the request boundary.""" input_per_1k, output_per_1k = prices[record.model] list_micro = ( record.prompt_tokens * input_per_1k + record.completion_tokens * output_per_1k + 500 ) // 1000 return (list_micro * record.price_multiplier_ppm + 500_000) // 1_000_000 def window_end(timestamp_ms: int, size_ms: int = MINUTE_MS) -> int: return (timestamp_ms // size_ms + 1) * size_ms def aggregate_costs( records: Iterable[RequestRecord], *, timestamps: Mapping[str, int] | None = None, ) -> list[TenantCostMinute]: """Aggregate by tenant-minute; timestamps can substitute processing/arrival time in Lab 4.""" totals: dict[tuple[str, int], tuple[int, int]] = {} for record in records: ts = timestamps[record.request_id] if timestamps is not None else record.completed_ts key = record.tenant_id, window_end(ts) micro, count = totals.get(key, (0, 0)) totals[key] = micro + request_cost_micro_usd(record), count + 1 return [TenantCostMinute(t, end, micro, count) for (t, end), (micro, count) in sorted(totals.items())] def salt_for(request_id: str, buckets: int = 8) -> int: return zlib.crc32(request_id.encode("utf-8")) % buckets def partial_costs(records: Iterable[RequestRecord], buckets: int = 8) -> list[PartialCost]: totals: dict[tuple[str, int, int], tuple[int, int]] = {} for record in records: key = record.tenant_id, salt_for(record.request_id, buckets), window_end(record.completed_ts) micro, count = totals.get(key, (0, 0)) totals[key] = micro + request_cost_micro_usd(record), count + 1 return [PartialCost(t, salt, end, micro, count) for (t, salt, end), (micro, count) in sorted(totals.items())] def merge_partials(updates: Sequence[PartialCost]) -> list[TenantCostMinute]: """Replace a salt's prior value; a late window re-fire is a correction, not another add.""" latest: dict[tuple[str, int, int], PartialCost] = {} for update in updates: latest[update.tenant_id, update.window_end, update.salt] = update totals: dict[tuple[str, int], tuple[int, int]] = {} for update in latest.values(): key = update.tenant_id, update.window_end micro, count = totals.get(key, (0, 0)) totals[key] = micro + update.micro_usd, count + update.requests return [TenantCostMinute(t, end, micro, count) for (t, end), (micro, count) in sorted(totals.items())] def upsert_minutes(emissions: Iterable[TenantCostMinute]) -> dict[tuple[str, int], TenantCostMinute]: """The billing-store view: recovery duplicates and late corrections share one natural key.""" result: dict[tuple[str, int], TenantCostMinute] = {} for emission in emissions: result[emission.key()] = emission return result def with_negotiated_price(record: RequestRecord, multiplier_ppm: int) -> RequestRecord: return replace(record, price_multiplier_ppm=multiplier_ppm, price_source="negotiated") # The billing service's price book. The job's price lookup (stage 9 on) and the invoice oracle both # read it, so they cannot disagree about what a tenant pays. NEGOTIATED_PRICE_PPM = {"acme": 900_000} def negotiated_price(record: RequestRecord) -> RequestRecord: """The price lookup's answer: the tenant's negotiated multiplier, or the list price.""" multiplier = NEGOTIATED_PRICE_PPM.get(record.tenant_id) return record if multiplier is None else with_negotiated_price(record, multiplier) def operator_watermark(partition_watermarks: Mapping[int, int], idle: Iterable[int] = ()) -> int: """The minimum active input watermark; useful as the deterministic oracle in Lab 5.""" idle_set = set(idle) active = [value for partition, value in partition_watermarks.items() if partition not in idle_set] if not active: raise ValueError("an operator watermark needs at least one active input") return min(active) def replay_from_checkpoint( records: Sequence[RequestRecord], checkpoint_index: int ) -> list[TenantCostMinute]: """Model restore-and-replay: the final materialized invoice must equal a failure-free run.""" before = aggregate_costs(records[:checkpoint_index]) replayed = aggregate_costs(records[checkpoint_index:]) # In a real window operator the checkpoint also contains open accumulators. Re-aggregating the # full logical input is the business oracle against which the cluster output is compared. del before, replayed return aggregate_costs(records)
common/flink_job.py
"""The one PyFlink GatewayMeter job, parameterised by the course stage. Each lab's ``gatewaymeter/job.py`` calls :func:`run_stage` with its module number. Keeping the operators here makes changes cumulative and keeps frozen UIDs spelled once. The lab README tells the learner which section to inspect and which environment switch deliberately breaks it. """ from __future__ import annotations import asyncio import json import os from common.events import REQUEST_COMPLETED, REQUEST_STARTED, TOKEN_GENERATED, parse_event from common.gatewaymeter import ( ABANDON_AFTER_MS, MINUTE_MS, OpenRequest, PartialCost, RequestRecord, TenantCostMinute, abandoned_record, negotiated_price, request_cost_micro_usd, salt_for, ) BOOTSTRAP = "kafka:19092" INPUT_TOPIC = "gateway-events" INPUT_PARTITIONS = 8 # gateway-events' partitions; from stage 4, one source subtask each LATENESS_MS = 2 * MINUTE_MS def _types(): from pyflink.common import Types return Types.PICKLED_BYTE_ARRAY() def _source(env, stage: int): from pyflink.common import Duration, WatermarkStrategy from pyflink.common.serialization import SimpleStringSchema from pyflink.datastream.connectors.kafka import KafkaOffsetsInitializer, KafkaSource from pyflink.common.watermark_strategy import TimestampAssigner source = ( KafkaSource.builder() .set_bootstrap_servers(BOOTSTRAP) .set_topics(INPUT_TOPIC) .set_group_id(f"gatewaymeter-m{stage:02d}") .set_starting_offsets(KafkaOffsetsInitializer.earliest()) .set_value_only_deserializer(SimpleStringSchema()) .build() ) if stage < 4: raw = env.from_source(source, WatermarkStrategy.no_watermarks(), "gateway-events") return raw.uid("gateway-events").map(parse_event, output_type=_types()).name("parse").uid("parse") class EventTime(TimestampAssigner): def extract_timestamp(self, value, record_timestamp): return value.ts # Event time is Event.ts. PyFlink's from_source hands the source only the Java half of a # WatermarkStrategy and drops a Python TimestampAssigner, which would leave event time at the # moment the producer sent each record. So the source carries no watermarks, and # assign_timestamps_and_watermarks, which does run a Python assigner, generates them right after # it. Watermarks are then per source subtask; with one subtask per partition that is per # partition, which is what a silent partition (module 5) needs. It takes all eight slots. strategy = WatermarkStrategy.for_bounded_out_of_orderness(Duration.of_seconds(20)) if stage >= 5 and os.getenv("GM_DISABLE_IDLENESS") != "1": strategy = strategy.with_idleness(Duration.of_seconds(15)) strategy = strategy.with_timestamp_assigner(EventTime()) # last: with_idleness drops it parallelism = env.get_parallelism() env.set_parallelism(int(os.getenv("GM_SOURCE_PARALLELISM", str(INPUT_PARTITIONS)))) raw = env.from_source(source, WatermarkStrategy.no_watermarks(), "gateway-events") events = raw.uid("gateway-events").map(parse_event, output_type=_types()).name("parse").uid("parse") timed = events.assign_timestamps_and_watermarks(strategy).name("event-time").uid("event-time") env.set_parallelism(parallelism) return timed def _records(events, stage: int): from pyflink.common import Time, Types from pyflink.datastream.functions import KeyedProcessFunction from pyflink.datastream.state import StateTtlConfig, ValueStateDescriptor class RequestAssembler(KeyedProcessFunction): def open(self, runtime_context): descriptor = ValueStateDescriptor("open", _types()) if stage >= 3: ttl = ( StateTtlConfig.new_builder(Time.minutes(10)) .set_update_type(StateTtlConfig.UpdateType.OnCreateAndWrite) .set_state_visibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build() ) descriptor.enable_time_to_live(ttl) self.open_request = runtime_context.get_state(descriptor) def process_element(self, event, ctx): current = self.open_request.value() if event.event_type == REQUEST_STARTED: current = OpenRequest( event.request_id, event.tenant_id, event.model, event.ts, int(event.payload.get("prompt_tokens", 0)), ) self.open_request.update(current) if stage >= 6: ctx.timer_service().register_event_time_timer(event.ts + ABANDON_AFTER_MS) return if current is None: return if event.event_type == TOKEN_GENERATED: current.completion_tokens += int(event.payload.get("n", 0)) self.open_request.update(current) return if event.event_type != REQUEST_COMPLETED: return self.open_request.clear() yield RequestRecord( current.request_id, current.tenant_id, current.model, current.started_ts, event.ts, int(event.payload.get("latency_ms", event.ts - current.started_ts)), current.prompt_tokens, int(event.payload.get("completion_tokens", current.completion_tokens)), str(event.payload.get("status", "ok")), ) def on_timer(self, timestamp, ctx): current = self.open_request.value() if current is None: return self.open_request.clear() yield abandoned_record(current, timestamp) keyed = events.key_by( (lambda event: event.tenant_id) if os.getenv("GM_BREAK_KEY_BY") == "1" else (lambda event: event.request_id), key_type=Types.STRING(), ) return keyed.process(RequestAssembler(), output_type=_types()).name("assemble").uid("assemble") def _enrich(records, stage: int): if stage < 9: return records from pyflink.common import Time from pyflink.datastream.async_data_stream import AsyncDataStream from pyflink.datastream.functions import AsyncFunction class PriceLookup(AsyncFunction): def __init__(self, delay_seconds): self.delay_seconds = delay_seconds async def async_invoke(self, record): await asyncio.sleep(self.delay_seconds) # The same price book the invoice oracle uses: acme's negotiated 10% discount. return [negotiated_price(record)] return ( AsyncDataStream.unordered_wait( records, PriceLookup(float(os.getenv("GM_PRICE_DELAY_SECONDS", "0.02"))), Time.seconds(5), capacity=int(os.getenv("GM_ASYNC_CAPACITY", "100")), output_type=_types(), ) .name("enrich-price") .uid("enrich-price") ) def _cost_windows(records, stage: int): from pyflink.common import Time, Types from pyflink.datastream.functions import AggregateFunction, ProcessWindowFunction from pyflink.datastream.output_tag import OutputTag from pyflink.datastream.window import TumblingEventTimeWindows, TumblingProcessingTimeWindows class CostAggregate(AggregateFunction): def create_accumulator(self): return 0, 0 def add(self, record, accumulator): return accumulator[0] + request_cost_micro_usd(record), accumulator[1] + 1 def get_result(self, accumulator): return accumulator def merge(self, left, right): return left[0] + right[0], left[1] + right[1] class EmitCost(ProcessWindowFunction): def process(self, key, context, elements): micro, requests = next(iter(elements)) yield TenantCostMinute(key, context.window().end, micro, requests) class EmitPartial(ProcessWindowFunction): def process(self, key, context, elements): tenant, salt = key micro, requests = next(iter(elements)) yield PartialCost(tenant, salt, context.window().end, micro, requests) if stage >= 11: keyed = records.key_by( lambda record: (record.tenant_id, salt_for(record.request_id)), key_type=Types.TUPLE([Types.STRING(), Types.INT()]), ) windowed = keyed.window(TumblingEventTimeWindows.of(Time.minutes(1))) if stage >= 6: windowed = windowed.allowed_lateness(LATENESS_MS) partials = ( windowed.aggregate(CostAggregate(), EmitPartial(), accumulator_type=Types.TUPLE([Types.LONG(), Types.LONG()]), output_type=_types()) .name("tenant-cost-partial") .uid("tenant-cost-partial") ) return _merge_partial_stream(partials), None keyed = records.key_by(lambda record: record.tenant_id, key_type=Types.STRING()) assigner = ( TumblingProcessingTimeWindows.of(Time.minutes(1)) if stage == 4 and os.getenv("GM_CLOCK", "event") == "processing" else TumblingEventTimeWindows.of(Time.minutes(1)) ) windowed = keyed.window(assigner) late_tag = OutputTag("tenant-cost-late", _types()) if stage >= 6: windowed = windowed.allowed_lateness(LATENESS_MS).side_output_late_data(late_tag) result = ( windowed.aggregate(CostAggregate(), EmitCost(), accumulator_type=Types.TUPLE([Types.LONG(), Types.LONG()]), output_type=_types()) .name("tenant-cost") .uid("tenant-cost") ) return result, result.get_side_output(late_tag) if stage >= 6 else None def _merge_partial_stream(partials): from pyflink.common import Types from pyflink.datastream.functions import KeyedProcessFunction from pyflink.datastream.state import MapStateDescriptor class MergePartials(KeyedProcessFunction): def open(self, runtime_context): self.parts = runtime_context.get_map_state( MapStateDescriptor("partials", Types.STRING(), _types()) ) def _total(self, tenant_id, end): values = [value for value in self.parts.values() if value.window_end == end] return TenantCostMinute( tenant_id, end, sum(value.micro_usd for value in values), sum(value.requests for value in values), ) def process_element(self, partial, ctx): self.parts.put(f"{partial.window_end}:{partial.salt}", partial) fire_at = partial.window_end - 1 cleanup_at = partial.window_end + LATENESS_MS if ctx.timer_service().current_watermark() >= fire_at: yield self._total(partial.tenant_id, partial.window_end) else: ctx.timer_service().register_event_time_timer(fire_at) ctx.timer_service().register_event_time_timer(cleanup_at) def on_timer(self, timestamp, ctx): if timestamp % MINUTE_MS == MINUTE_MS - 1: yield self._total(ctx.get_current_key(), timestamp + 1) return end = timestamp - LATENESS_MS for key in list(self.parts.keys()): if key.startswith(f"{end}:"): self.parts.remove(key) return ( partials.key_by(lambda partial: partial.tenant_id, key_type=Types.STRING()) .process(MergePartials(), output_type=_types()) .name("tenant-cost") .uid("tenant-cost") ) def _configure_recovery(env, stage: int): if stage < 7: return from pyflink.common import Configuration from pyflink.datastream import CheckpointingMode, ExternalizedCheckpointRetention env.enable_checkpointing(10_000, CheckpointingMode.EXACTLY_ONCE) checkpoint = env.get_checkpoint_config() checkpoint.set_checkpoint_timeout(60_000) checkpoint.set_min_pause_between_checkpoints(2_000) checkpoint.set_max_concurrent_checkpoints(1) checkpoint.set_externalized_checkpoint_retention( ExternalizedCheckpointRetention.RETAIN_ON_CANCELLATION ) if stage >= 9 and os.getenv("GM_UNALIGNED") == "1": checkpoint.enable_unaligned_checkpoints() config = Configuration() config.set_string("state.backend.type", os.getenv("GM_STATE_BACKEND", "rocksdb")) config.set_string("execution.checkpointing.storage", "filesystem") config.set_string("execution.checkpointing.dir", "file:///checkpoints") config.set_string("restart-strategy.type", "fixed-delay") config.set_string("restart-strategy.fixed-delay.attempts", os.getenv("GM_RESTART_ATTEMPTS", "3")) config.set_string("restart-strategy.fixed-delay.delay", "10 s") env.configure(config) def _sink(costs, stage: int): from pyflink.common import Types if stage < 8: costs.map(lambda value: json.dumps(value.to_dict(), sort_keys=True), output_type=Types.STRING()).print() return from pyflink.common.serialization import SimpleStringSchema from pyflink.datastream.connectors.base import DeliveryGuarantee from pyflink.datastream.connectors.kafka import KafkaRecordSerializationSchema, KafkaSink guarantee_name = os.getenv("GM_SINK_GUARANTEE", "exactly_once") guarantee = ( DeliveryGuarantee.AT_LEAST_ONCE if guarantee_name == "at_least_once" else DeliveryGuarantee.EXACTLY_ONCE ) serializer = ( KafkaRecordSerializationSchema.builder() .set_topic("tenant-cost") .set_value_serialization_schema(SimpleStringSchema()) .build() ) builder = ( KafkaSink.builder() .set_bootstrap_servers(BOOTSTRAP) .set_record_serializer(serializer) .set_delivery_guarantee(guarantee) .set_property("transaction.timeout.ms", "900000") ) if guarantee == DeliveryGuarantee.EXACTLY_ONCE: builder = builder.set_transactional_id_prefix( os.getenv("GM_TRANSACTIONAL_ID_PREFIX", f"gatewaymeter-m{stage:02d}-tenant-cost") ) strings = costs.map(lambda value: json.dumps(value.to_dict(), sort_keys=True), output_type=Types.STRING()) strings.sink_to(builder.build()).name("sink-tenant-cost").uid("sink-tenant-cost") def build_stage(env, stage: int): from pyflink.common import Types _configure_recovery(env, stage) env.set_parallelism(int(os.getenv("GM_PARALLELISM", "4"))) if stage >= 10: env.set_max_parallelism(int(os.getenv("GM_MAX_PARALLELISM", "128"))) env.add_python_file("/labs/common") events = _source(env, stage) records = _records(events, stage) if stage <= 3: records.print() return records records = _enrich(records, stage) costs, late = _cost_windows(records, stage) if late is not None: late.map(lambda value: f"LATE {value.request_id}", output_type=Types.STRING()).print() _sink(costs, stage) return costs def run_stage(stage: int) -> None: from pyflink.datastream import StreamExecutionEnvironment env = StreamExecutionEnvironment.get_execution_environment() build_stage(env, stage) env.execute(f"GatewayMeter - module {stage}")
common/reset_tenant_cost.py
#!/usr/bin/env python3 """Reset the local ``tenant-cost`` topic for one isolated billing experiment. Exactly-once recovery applies within one submitted job. It does not deduplicate records produced by several independent GatewayMeter jobs. This tool creates a clean observation boundary while protecting a running lab from accidental data loss: it refuses to proceed while the local Flink cluster reports an active job. ``--with-input`` also empties ``gateway-events``. Every stage reads that topic from the earliest offset, so without it the capstone would re-read the traffic of every earlier lab alongside the trace its oracle describes. Flink cannot see a producer left running from an earlier lab, so the tool then watches the empty topic for a few seconds and fails if anything is still writing to it. """ from __future__ import annotations import argparse import json from pathlib import Path import subprocess import time import urllib.request ROOT = Path(__file__).resolve().parents[1] FLINK_JOBS_URL = "http://127.0.0.1:8081/jobs/overview" TOPIC = "tenant-cost" TOPIC_PARTITIONS = {"tenant-cost": 4, "gateway-events": 8} # tenant-cost keeps every record: the sink stamps each result with its event time, and a seeded # trace's minutes are older than Kafka's 7-day retention, which would delete them within minutes. TOPIC_CONFIG = {"tenant-cost": ["--config", "retention.ms=-1"]} QUIET_SECONDS = 5 def active_job_ids(payload: dict) -> list[str]: terminal = {"FINISHED", "FAILED", "CANCELED", "SUSPENDED"} return [ str(job["jid"]) for job in payload.get("jobs", []) if str(job.get("state", "")).upper() not in terminal ] def flink_active_jobs() -> list[str]: try: with urllib.request.urlopen(FLINK_JOBS_URL, timeout=3) as response: return active_job_ids(json.load(response)) except Exception as exc: raise SystemExit( "Cannot read the local Flink job list at http://127.0.0.1:8081. " "Start the compose cluster before resetting the topic." ) from exc def kafka_topics(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [ "docker", "compose", "exec", "-T", "kafka", "/opt/kafka/bin/kafka-topics.sh", "--bootstrap-server", "localhost:19092", *args, ], cwd=ROOT, check=True, text=True, capture_output=True, ) def records_in(topic: str) -> int: """Sum of the end offsets over the topic's partitions.""" out = subprocess.run( [ "docker", "compose", "exec", "-T", "kafka", "/opt/kafka/bin/kafka-get-offsets.sh", "--bootstrap-server", "localhost:19092", "--topic", topic, ], cwd=ROOT, check=True, text=True, capture_output=True, ).stdout return sum(int(line.rsplit(":", 1)[1]) for line in out.splitlines() if line.startswith(f"{topic}:")) def reset_topic(topic: str) -> None: kafka_topics("--delete", "--if-exists", "--topic", topic) for _ in range(30): listed = kafka_topics("--list").stdout.splitlines() if topic not in listed: break time.sleep(1) else: raise SystemExit(f"Timed out waiting for {topic} deletion") partitions = TOPIC_PARTITIONS[topic] kafka_topics( "--create", "--topic", topic, "--partitions", str(partitions), "--replication-factor", "1", *TOPIC_CONFIG.get(topic, []), ) print(f"Reset {topic}: empty topic with {partitions} partitions") def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--with-input", action="store_true", help="also empty gateway-events (module 12's clean trace)") args = parser.parse_args(argv) topics = [TOPIC, "gateway-events"] if args.with_input else [TOPIC] active = flink_active_jobs() if active: joined = ", ".join(active) raise SystemExit( f"Refusing to reset {' and '.join(topics)}: active Flink job(s): {joined}. " "Cancel them in the Web UI, then run this command again." ) for topic in topics: reset_topic(topic) if args.with_input: time.sleep(QUIET_SECONDS) written = records_in("gateway-events") if written: raise SystemExit( f"gateway-events received {written} records in the {QUIET_SECONDS} s after the reset: " "a producer from an earlier lab is still running. Stop it, then reset again." ) if __name__ == "__main__": main()
Lab source: 12-capstone-gatewaymeter 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.