Producer/consumer log pipeline with a bounded queue
Problem statement
A log shipper reads lines from a file much faster than it can parse them. If the reader simply loads everything into memory, a 50 GB log takes the host down. Build a pipeline with one producer (the main thread here) that reads lines and three consumer threads that parse them, connected by a queue.Queue(maxsize=5).
- When the queue is full, the producer must wait (back-pressure), so memory stays bounded however big the input is.
- Each line looks like
2026-09-24T10:03:00Z api INFO request handled. Consumers count lines per(service, level). - Malformed lines (fewer than three fields) are counted as skipped; they must not kill a consumer thread.
- When the input ends, every consumer must stop cleanly, and the program must not hang. Use a sentinel value.
- Print counts sorted by service and level, the parsed/skipped totals, and whether the queue ever grew beyond its limit.
The input comes from a generator standing in for the file: 60 lines, service ["api", "web", "worker"][i % 3], level ERROR when i % 10 == 0 else INFO, and every line with i % 25 == 24 replaced by garbage-line-without-fields.
Examples
Example 1
Input: python solution.py
Output: api ERROR 2
api INFO 17
web ERROR 2
web INFO 17
worker ERROR 2
worker INFO 18
parsed 58 lines, skipped 2 malformed
queue never held more than 5: True
Explanation: Lines 24 and 49 are garbage. Line 24 would have been api and line 49 web, so worker, which lost nothing, has one more INFO line than the others. Lines 0, 30 (api), 10, 40 (web) and 20, 50 (worker) are errors.
Hints
Approach
Optimal
- Bounded queue.
queue.Queue(maxsize=5)is thread-safe and blocks the producer input()when it holds 5 items. The producer can never get more than 5 lines ahead of the consumers, so memory use is constant whatever the file size. The script records the largestqsize()it saw to show the bound held. - Shutdown with sentinels. After the last line the producer puts one
STOPobject per consumer. A consumer that takesSTOPreturns. Using a uniqueobject()rather thanNoneor an empty string means no real input can be mistaken for the signal. Because each consumer takes exactly one sentinel, all of them stop. - Failures stay local. Parsing a malformed line raises
ValueErroron the unpacking; it is recorded and the loop continues. The outerfinallyhands the consumer's partialCounterback even if something unexpected happens, andtask_done()in the innerfinallykeeps the queue's bookkeeping right, soq.join()would still work. - No shared mutable state during the work. Each consumer counts into its own
Counter. The main thread adds them together afterjoin, so the counting itself needs no lock. - No silent hangs.
join(timeout=10)plus anis_alive()check turns a stuck consumer into a clear error instead of a process that never exits. - Deterministic output. Which consumer handled which line differs every run, but the merged totals do not, and they are printed sorted.
n is the number of lines and k the number of (service, level) pairs.
O(n)Space O(queue size + k)import queueimport threadingimport timefrom collections import Counter QUEUE_SIZE = 5N_CONSUMERS = 3STOP = object() # sentinel: "no more work" def fake_log_lines(): # Stands in for tailing a big log file: 60 lines, a few of them malformed. for i in range(60): if i % 25 == 24: yield "garbage-line-without-fields" else: svc = ["api", "web", "worker"][i % 3] level = "ERROR" if i % 10 == 0 else "INFO" yield f"2026-09-24T10:{i:02d}:00Z {svc} {level} request handled" def producer(q, stats): for line in fake_log_lines(): q.put(line) # blocks while the queue is full: back-pressure stats["max_qsize"] = max(stats["max_qsize"], q.qsize()) for _ in range(N_CONSUMERS): q.put(STOP) # one sentinel per consumer def consumer(q, results, errors): local = Counter() # no shared state while working try: while True: item = q.get() try: if item is STOP: return time.sleep(0.001) # pretend parsing takes a moment _, svc, level, *_ = item.split() local[(svc, level)] += 1 except ValueError: errors.append(item) # list.append is thread-safe in CPython finally: q.task_done() finally: results.append(local) # hand the partial result back even on failure q = queue.Queue(maxsize=QUEUE_SIZE)stats = {"max_qsize": 0}results, errors = [], []consumers = [threading.Thread(target=consumer, args=(q, results, errors)) for _ in range(N_CONSUMERS)]for t in consumers: t.start()producer(q, stats)for t in consumers: t.join(timeout=10) if t.is_alive(): raise SystemExit("a consumer is stuck") total = sum(results, Counter())for (svc, level), n in sorted(total.items()): print(f"{svc:<7}{level:<6}{n}")print(f"parsed {sum(total.values())} lines, skipped {len(errors)} malformed")print(f"queue never held more than {QUEUE_SIZE}: {stats['max_qsize'] <= QUEUE_SIZE}")Follow-up questions
- Add a second stage: parsed records go through another bounded queue to a single writer thread.
- Handle Ctrl+C: stop reading, let the consumers drain what is queued, then exit.
- The consumer occasionally hangs on one record. Add a per-record timeout without killing the thread.
Frequently asked questions
Log shippers, metrics pipelines and job workers are all producer/consumer systems. Interviewers use this to check that you understand back-pressure, clean shutdown and error isolation, the three things that go wrong in real pipelines: memory blow-ups, hung processes and one bad record killing a worker.
Then the producer never waits. If it is faster than the consumers, the queue grows until the process runs out of memory, which is the exact failure the pipeline is meant to prevent. A bounded queue turns a speed mismatch into waiting instead of a crash.
Not much on standard CPython, because the GIL lets only one thread run Python bytecode at a time. For CPU-bound parsing, use multiprocessing (with multiprocessing.Queue) or concurrent.futures.ProcessPoolExecutor. The design (bounded queue, sentinels, merge at the end) stays the same.