Basic DE Applications

In a pipeline, top-K rarely arrives as a tidy list of numbers. It arrives as events that must be summed by key or ranked within groups, and often the ranking runs on many partitions whose results have to be merged. Each shape reuses the capped heap and adds a correctness rule of its own.

Aggregate first, then rank

Top 2 customers by revenue, computed from order events, takes 2 steps. A dict sums each customer's orders in a single O(n) pass, then a heap ranks the m distinct customers in O(m log k). Ranking the raw events instead would return c3 and c4 because they placed the 2 largest single orders, which answers a different question.

import heapq
from collections import defaultdict

orders = [
    ("c1", 120.0), ("c2", 45.5), ("c3", 300.0),
    ("c1", 250.0), ("c4", 300.0), ("c2", 210.0),
]
revenue = defaultdict(float)
for customer, amount in orders:
    revenue[customer] += amount

top2 = heapq.nsmallest(2, revenue.items(), key=lambda kv: (-kv[1], kv[0]))
print(top2)

Customers c3 and c4 tie at 300.0 for second place. The key, negated total then customer ID, hands the place to c3, so a rerun that reads the orders in a different sequence prints the same answer. With heapq.nlargest(2, revenue.items(), key=lambda kv: kv[1]), the tie between c3 and c4 falls back to the dict's insertion order. That order follows the sequence the orders were read in, so 2 runs over the same orders can publish different rankings.

When totals keep changing

The capped heap assumes each key arrives once with its final value. In a running aggregation, c2 would enter at 45.5 and climb to 255.5 when its second order lands, while the heap keeps the stale 45.5 entry with no way to find and update it. For a batch, rank once after the sums finish. For an hourly refresh, keep running totals in the dict and rebuild the top-K from it with nsmallest each hour, at O(m log k) per refresh.

Top-K per group: a dict of heaps

Finding the 2 slowest queries in each region takes 1 heap per group. A dict maps each region to its own list, setdefault creates that list the first time a region appears, and every event runs the push-or-replace step against its region's heap. Memory is k entries per group, so 50 regions at k = 2 hold 100 entries however long the log runs.

import heapq

events = [
    ("us", "q1", 340), ("eu", "q2", 120), ("us", "q3", 90),
    ("us", "q4", 510), ("eu", "q5", 800), ("eu", "q6", 60),
    ("us", "q7", 420),
]
k = 2
slowest = {}
for region, query_id, ms in events:
    heap = slowest.setdefault(region, [])
    if len(heap) < k:
        heapq.heappush(heap, (ms, query_id))
    elif ms > heap[0][0]:
        heapq.heapreplace(heap, (ms, query_id))

for region in sorted(slowest):
    print(region, sorted(slowest[region], reverse=True))
eu [(800, 'q5'), (120, 'q2')]
us [(510, 'q4'), (420, 'q7')]

The eu heap rejects q6 at 60 ms because its root is already 120. The us heap fills at q3. After that, q4 evicts the root at the time, which is 90, and q7 evicts the next root, 340. Iterating over sorted(slowest) keeps the region order fixed from run to run.

Merging top-K across partitions

A distributed job computes a local top-K on each partition and merges the results. The global top-K is guaranteed to lie inside the union of the local results only when each key's full total lives on a single partition, which is what partitioning by that key provides. Partition by time instead and each total is split, so the merge can miss the true leader.

Merging the local results sees alice at 100 and bob at 90 ahead of carol at 85 and dave at 70, and reports alice and bob. The true totals put carol first at 165, ahead of alice at 100 and bob at 95, with dave last at 70, so the actual leader is missing: carol's 80 in hour 1 was cut by that partition's top 2. The fix is to shuffle rows so each customer lands on a single partition and gets summed there. Only after that does each partition take its local top-K.

Rows reach a partition by customer, so each total is complete before its partition keeps k entries, and the merge ranks at most 8 x k candidates.
  • Order events (source, event stream)
  • Shuffle by customer (queue, hash(customer_id) mod 8)
  • Sum and local top-K (transform, dict + capped heap); parallelism: 8 partitions
  • Merge top-K (transform, nsmallest over 8 x k rows)
  • Revenue dashboard (consumer, top-K table)
  • Order events -> Shuffle by customer
  • Shuffle by customer -> Sum and local top-K
  • Sum and local top-K -> Merge top-K
  • Merge top-K -> Revenue dashboard

Where else the pattern runs

About this lesson

This section is part of the Heap & Top-K: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.