Lesson 10 / 11
State in Stream Engines
Counting how much a failed worker asks to have back in a job that flows continuously, and whether raising the checkpoint frequency really lowers that number: in a streaming job of 2200 rows, when a failure occurs in period 2026-09, 1600 rows are recomputed without a checkpoint, 400 at interval 6, again 400 at interval 3, and 0 at interval 1. Halving the interval does not halve the recovery, because the last point before the failure has not moved; interval 4 gives 0 on the same failure. When all eleven failure points are scanned, interval 6 makes 545.5 rows on average be recomputed and interval 3 makes 218.2, but the certain cost rises from 400 to 800; by the worst case interval 4 and 3 tie, by the average interval 4 wins. When the failure probability drops from 1 to 0.5, the best choice turns back to holding no checkpoint. With partitioned state a single failed worker asks for 408 rows instead of 1600 and 102 instead of 400, and exactly-once requires holding exactly that many rows of state.
Published Updated
In this lesson
- Counts the rows recomputed after a failure in a streaming job by checkpoint interval.
- Compares the certain cost of a checkpoint with the probabilistic gain of recovery and justifies the interval decision by the failure probability.
- Calculates the rows a single failed worker asks to have back under partitioned state and measures the state that exactly-once semantics holds.
Contents
The previous lesson counted a failed worker only as the loss of the cache: what disappeared was a stored intermediate result, it could be recomputed, and the total of two queries rose from 3300 to 5500. That is what is right in a batch job, because the input is still standing where it was.
In a job that flows continuously the situation is different. What sits in the worker’s memory is not an intermediate result but the state of the computation up to that moment: which subscriber has been reached up to which period, where which partial total has stopped. When a failure occurs, this state is lost and the job has to start again from somewhere. This lesson counts a single question: how much the failed worker asks to have back, and whether the cost paid to shrink that number really pays for itself.
- PF47. The source, the pipeline and the distributed setup are fictional and are generated within the lesson. No stream engine, cluster manager or state store is called. The seed is 20260218.
- PF48. The procedures for the checkpoint, resilience to duplicates and exactly-once semantics were measured in the Data Ingestion topic of the Data Pipelines and Orchestration course of the Data Engineering and MLOps curriculum and in the Caching, Queues and Asynchronous Processing course of the Backend Development curriculum; they are not repeated. The numbers there are input here: 2200 rows at least once with 19 duplicates, 2162 rows at most once. The only measure here is the cost of recovery.
- PF49. The streaming job advances period by period and the failure occurs in period
2026-09; the job continues from the last checkpoint before the failure. In the code thecheckpointsfield carries the number of checkpoints. - PF50. A checkpoint makes the current state durable; the state is one entry per subscriber,
that is,
STATE_ROWS = 200rows. This is a choice, and all the totals below are sensitive to it. - PF51. When averaging, the failure is assumed to occur with equal probability in any one of the eleven periods; this too is a choice. The row is the model’s own unit, not raw milliseconds.
- PF52. The baseline is the setup that holds no checkpoint: point 0, certain cost 0, and after a failure it starts over from the beginning.
The Cost of Recovery
The first block sets up the source, the three stages of the pipeline up to consumption, and the
recovery measure. recovery counts how many of the rows up to the failure period will be
recomputed.
# FICTIONAL. Meter readings of the measurement network, the first three stages of the pipeline and # the streaming job are generated within the lesson; no stream engine , cluster manager # or state store is called. Seed 20260218. SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value REGION = ["dogu", "bati", "kuzey", "guney", "merkez"] TYPE = ["residential", "commercial"] PERIODS = [f"2026-{a:02d}" for a in range(1, 13)] SUBSCRIBERS = {} for i in range(200): r = rng(SEED + 37 * i) SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)], "start": 10000 + int(r() * 60000)} def generate_source(): records = [] for i, (sub, a) in enumerate(SUBSCRIBERS.items()): e = a["start"] for p in PERIODS: r = rng(SEED + 101 * i + 7 * PERIODS.index(p)) e += 4 + int(r() * 46) records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"], "index": e, "unit": "m3"}) return records def s1_screen(records): REQUIRED = ("subscriber", "period", "region", "index", "unit") keep = [k for k in records if all(a in k for a in REQUIRED)] return keep, len(records) - len(keep) def s2_dedupe(records): seen, keep = set(), [] for k in records: key = (k["subscriber"], k["period"]) if key in seen: continue seen.add(key) keep.append(k) return keep, len(records) - len(keep) def s3_consumption(records): table = {(k["subscriber"], k["period"]): k for k in records} out, dropped = [], 0 for k in records: i = PERIODS.index(k["period"]) if i == 0: continue prev = table.get((k["subscriber"], PERIODS[i - 1])) if prev is None: dropped += 1 continue out.append(dict(k, m3=int(k["index"]) - int(prev["index"]))) return out, dropped WORKERS, NETWORK_COST, TASK_OVERHEAD, STATE_ROWS = 4, 3, 50, 200 def rows(): v, _ = s1_screen(generate_source()) v, _ = s2_dedupe(v) v, _ = s3_consumption(v) return v def recovery(records, checkpoint_interval, failed_period="2026-09", workers=WORKERS): """Without a checkpoint , from the start; with one , from the last point.""" i = PERIODS.index(failed_period) if checkpoint_interval is None: recomputed = sum(1 for k in records if PERIODS.index(k["period"]) <= i) return {"checkpoints": 0, "recomputed": recomputed} last = (i // checkpoint_interval) * checkpoint_interval recomputed = sum(1 for k in records if last < PERIODS.index(k["period"]) <= i) return {"checkpoints": len(PERIODS) // checkpoint_interval, "recomputed": recomputed} STREAM = rows() print(f"stream job {len(STREAM)} rows , failure 2026-09") print("interval checkpoints recomputed rows") for a in (None, 6, 3, 1): s = recovery(STREAM, a) print(f"{str(a):>8} {s['checkpoints']:>11} {s['recomputed']:>15}")
stream job 2200 rows , failure 2026-09
interval checkpoints recomputed rows
None 0 1600
6 2 400
3 4 400
1 12 0
Three of the four rows are what one would expect. The job that holds no checkpoint recomputes all 1600 rows up to the failure period; interval 6 lowers this to 400; interval 1 lowers it to zero because it saves every period. The third row is not what one would expect: going from interval 6 to interval 3 does not change the recomputed rows at all. The interval has been cut in two, the number of held points has gone from 2 to 4, and the recovery has stayed at 400. What is paid has doubled; what is received has stayed the same.
Halving the Interval
The reason stands in a single calculation. The failure is 2026-09, that is, the eighth in the index
of twelve. At interval 6 the last point before the failure is the sixth period; at interval 3 it is
also the sixth period, because the remainder of dividing eight by three, two, carries the last point
back to six. The interval was halved but the last point before the failure did not move. The second
block scans this behavior across all eleven failure points.
# This block continues the definitions of STREAM, PERIODS, STATE_ROWS and recovery from the first block. INTERVALS = (None, 6, 4, 3, 2, 1) print("failure " + " ".join(f"{str(a):>5}" for a in INTERVALS)) for d in PERIODS[1:]: print(f"{d} " + " ".join(f"{recovery(STREAM, a, d)['recomputed']:>5}" for a in INTERVALS)) print("\ninterval checkpoints certain cost worst worst total average average total") for a in INTERVALS: y = [recovery(STREAM, a, d)["recomputed"] for d in PERIODS[1:]] k = recovery(STREAM, a)["checkpoints"] * STATE_ROWS o = sum(y) / len(y) print(f"{str(a):>8} {recovery(STREAM, a)['checkpoints']:>11} {k:>12} {max(y):>5}" f" {max(y) + k:>11} {round(o, 1):>7} {round(o + k, 1):>13}")
failure None 6 4 3 2 1
2026-02 200 200 200 200 200 0
2026-03 400 400 400 400 0 0
2026-04 600 600 600 0 200 0
2026-05 800 800 0 200 0 0
2026-06 1000 1000 200 400 200 0
2026-07 1200 0 400 0 0 0
2026-08 1400 200 600 200 200 0
2026-09 1600 400 0 400 0 0
2026-10 1800 600 200 0 200 0
2026-11 2000 800 400 200 0 0
2026-12 2200 1000 600 400 200 0
interval checkpoints certain cost worst worst total average average total
None 0 0 2200 2200 1200.0 1200.0
6 2 400 1000 1400 545.5 945.5
4 3 600 600 1200 327.3 927.3
3 4 800 400 1200 218.2 1018.2
2 6 1200 200 1400 109.1 1309.1
1 12 2400 0 2400 0.0 2400.0
The matrix shows by name where the gain comes from: from the distance of the failure to the last
point. In 2026-07 interval 6 gives zero, because the failure has landed exactly on the point; the
same interval gives 1000 in 2026-12. Interval 4 gives 0 in 2026-09 while interval 3 gives 400
there; the setup that holds points more often is worse in that failure. None of the six columns beats
the others in every row.
The average column erases this noise and behaves regularly: 1200.0 → 545.5 → 327.3 → 218.2 → 109.1 → 0. On average, halving the interval roughly halves the recovery; in a single failure it does not. A setup experiences a single failure, not the average; whoever decides by the average meets the failure that actually happens.
The last two columns add the cost. Holding a checkpoint pays a certain cost over STATE_ROWS = 200:
400 at interval 6, 800 at interval 3, 2400 at interval 1. By the worst case the cheapest setup is
interval 4 and interval 3 with 1200, and by the average interval 4 with 927.3. The two criteria
give two different answers, and the rule “halve the interval” is the best in neither.
Certain Cost and Probabilistic Gain
The totals above carry a silent assumption: that there is a failure in every run. The third block relaxes this assumption and gives the same decision again at four failure probabilities. Then it splits the job across four workers and counts how much the single failed worker asks to have back.
# This block continues the definitions of STREAM, PERIODS, WORKERS, STATE_ROWS, INTERVALS and # recovery from the first two blocks. def trace_id(m): h = 2166136261 for c in str(m): h = ((h ^ ord(c)) * 16777619) & 0xFFFFFFFF return h def distribute(records, key, workers=WORKERS): buckets = {i: [] for i in range(workers)} for k in records: buckets[trace_id(k[key]) % workers].append(k) return buckets print("failure probability " + " ".join(f"{str(a):>6}" for a in INTERVALS) + " best") for p in (1.0, 0.75, 0.5, 0.25): s = [] for a in INTERVALS: y = [recovery(STREAM, a, d)["recomputed"] for d in PERIODS[1:]] s.append(p * sum(y) / len(y) + recovery(STREAM, a)["checkpoints"] * STATE_ROWS) print(f"{p:>19} " + " ".join(f"{round(v, 1):>6}" for v in s) + f" {str(INTERVALS[s.index(min(s))]):>6}") BUCKETS = distribute(STREAM, "subscriber") print("\ninterval whole job from scratch busiest worker with partitioned state exactly-once state") for a in (None, 6, 3, 1): g = recovery(STREAM, a)["recomputed"] per = max(recovery(BUCKETS[i], a)["recomputed"] for i in range(WORKERS)) print(f"{str(a):>8} {g:>22} {per:>37} {g:>18}")
failure probability None 6 4 3 2 1 best
1.0 1200.0 945.5 927.3 1018.2 1309.1 2400.0 4
0.75 900.0 809.1 845.5 963.6 1281.8 2400.0 6
0.5 600.0 672.7 763.6 909.1 1254.5 2400.0 None
0.25 300.0 536.4 681.8 854.5 1227.3 2400.0 None
interval whole job from scratch busiest worker with partitioned state exactly-once state
None 1600 408 1600
6 400 102 400
3 400 102 400
1 0 0 0
The first table shows that the interval decision depends not on the workload but on the failure frequency. If there is a failure in every run the best choice is interval 4; at a probability of three quarters it moves to interval 6; when the probability is halved the best choice is to hold no checkpoint at all. The reason is one sentence: the cost of a checkpoint is certain, its gain is probabilistic. The certain cost is paid in every run; the gain materializes only when a failure occurs and only as much as the distance of the failure to the last point.
How Much the Failed Worker Asks to Have Back
The second table answers the opening question of the lesson. If the job is split across four workers
by the subscriber key and the state is held at each worker for its own partition, the single
failed worker asks to have back not the whole job but only its own share: 408 rows instead of 1600,
102 instead of 400. Partitioned state lowers the recovery to a quarter, but only on the condition
that the state is held partitioned. If the state is gathered in one place, or if the engine restarts
the whole job on a failure, the number asked back is again 1600 and 400. Partitioning makes recovery
cheaper only if the state is partitioned too.
The last column is the cost of exactly-once semantics, and its number is surprisingly familiar. The rows recomputed during recovery are written to the output a second time; the only way to prevent this is to remember which rows have been written since the last point. That set is exactly the set of recomputed rows: 1600 when there is no interval, 400 at intervals 6 and 3, 0 at interval 1. Exactly-once is holding as much state as the rows the recovery recomputes; making the interval denser makes recovery cheaper while it also shrinks this state, and the two gains come from the same number.
This set is itself a state, and it is lost together with the failed worker. If the list of written rows
stands only in memory, the recovery loses that too, and which row went to the output becomes unknown;
exactly-once is achieved only if the list is taken into the checkpoint. This carries the certain cost
beyond STATE_ROWS = 200: the point now holds not only the partial totals but also the identities of
the rows written since the last point. At interval 6 that is 400 identities, and at interval 3 also
400.
What happens if this is not held was measured in the Data Pipelines and Orchestration course: the at-least-once setup lets 2200 rows through, 19 duplicates occur and the reported deviation stays empty. The third claim of the course holds here too: as long as exactly-once is achieved, all four of the four intervals produce the same result. There is no way to tell from the result which interval was chosen; the difference is only in the cost paid.
Summary
- In the streaming job of 2200 rows, on a failure in
2026-09the recomputed rows are 1600 with no checkpoint, 400 at interval 6, again 400 at interval 3, and 0 at interval 1. - Halving the interval does not halve the recovery: the last point before the failure has not moved. On the same failure interval 4 gives 0 and interval 3 gives 400; the setup that holds points more often can be worse.
- The average over the eleven failure points is regular (1200.0 → 545.5 → 327.3 → 218.2 → 109.1 → 0), but a setup experiences a single failure, not the average. By the worst case the cheapest is interval 4 and 3 with 1200, by the average interval 4 with 927.3.
- The cost of a checkpoint is certain, its gain is probabilistic: at failure probability 1 the best interval is 4, at 0.75 it is 6, and at 0.5 and below it is to hold no checkpoint.
- With partitioned state the single failed worker asks for 408 rows instead of 1600 and 102 instead of 400; partitioning makes recovery cheaper only if the state is partitioned too.
- Exactly-once is holding as much state as the rows the recovery recomputes: 1600, 400, 400 and 0. As long as this is achieved, all four intervals produce the same result and the difference is only in the cost.
Next Step
Five lessons wrote five separate names of the cost: the shuffled row, the copy, the skew, the held row and the recomputed row. Beneath all of them stood the same two numbers, how long a job took and how much work it did in total, and in every lesson these two were written separately. One question remains: with how many workers a cluster should be set up. The next lesson sweeps the number of workers from one to sixteen and shows that the total work rises while the makespan falls; it counts that this decision cannot be made without writing which one is paid, and closes the course.
To keep your progress and take notes, Log in
My notes
Log in to take notes.