Exercise 2: Removing the Lock from a Real Concurrent Purchase — Possible Solution ==================================================================== THE TEST ------------------------------ def buy_product_no_lock(cat, product_id, qty, results, errors, name): try: stock_row = [r for r in cat.scan('products') if r[0] == product_id][0] current_stock = stock_row[2] time.sleep(0.01) # the same forced interleaving window as Step 5 new_stock = current_stock - qty # ... read-modify-rewrite the product's own page directly, NO lock acquired ... results[name] = new_stock except Exception as e: errors[name] = repr(e) t1 = threading.Thread(target=buy_product_no_lock, args=(cat, 10, 30, results, errors, 'A')) t2 = threading.Thread(target=buy_product_no_lock, args=(cat, 10, 40, results, errors, 'B')) t1.start(); t2.start(); t1.join(); t2.join() RESULT ------------------------------ results: {'B': 60} errors: {'A': "error('unpack_from requires a buffer of at least 4 bytes for unpacking 4 bytes at offset 0 (actual buffer size is 0)')"} final stock (no locking): 60 Thread B "succeeds" but computes the WRONG answer (60, not the true 30 = 100-30-40). Thread A doesn't just get a wrong answer -- it crashes outright with a real struct.error. WHY THIS PRODUCES TWO DIFFERENT FAILURE MODES, NOT JUST ONE ------------------------------ Failure mode 1 -- the silent lost update -- is exactly Step 5's own original concern: both threads read the same starting stock (100) before either writes back, so one thread's own subtraction is based on stale data and silently overwrites the other's real work. Failure mode 2 -- the outright crash -- is a SECOND, independent danger that Step 5's locking also happened to prevent without this exercise's author originally intending to test for it: both threads share the exact same HeapFile object, which wraps one real, shared OS-level file handle. HeapFile.read_page() calls self.file.seek(...) immediately followed by self.file.read(...) -- two separate operations, not one atomic one. If thread B's own write_page() call (which ALSO calls self.file.seek()) executes in between thread A's seek() and read(), thread A's read() executes from a DIFFERENT file position than the one it just seeked to -- reading a torn, wrong- sized (or even zero-byte) chunk, which then fails to even parse as a valid page at all. WHY BOTH OUTCOMES TRACE BACK TO THE SAME MISSING LOCK ------------------------------ Step 5's exclusive lock, held across the WHOLE read-modify-write cycle, prevented both problems at once, for the same underlying reason: it guaranteed the two threads' own operations on this shared resource (both the LOGICAL row and the PHYSICAL file handle) never interleaved at all. Removing it doesn't just risk a wrong number -- it risks the two threads' own raw file I/O operations interleaving at the byte level, which is a more severe class of bug than a business-logic race condition alone. WHY THIS WORKS AS AN ANSWER ------------------------------ Running the exact same two purchases with the exact same lock simply removed, rather than describing what "might" go wrong in the abstract, surfaces BOTH real failure modes this specific engine is actually vulnerable to -- a silently wrong answer and a genuine crash -- and traces each one back precisely to what the lock was actually protecting: not just "the stock number," but the shared file handle's own read/write operations themselves.