Source code for pytc.test.test_async_hdf5_writer

"""Unit tests for ``pytc.utils.gpu_pipeline._AsyncHDF5Writer``.

These tests exercise the writer purely against in-memory callables, with
no real HDF5 file I/O — the contract we care about is:

* FIFO ordering of submitted jobs.
* ``drain()`` blocks until the queue is empty.
* Errors raised inside the worker are latched and re-raised on the next
  ``submit()`` / ``drain()`` / ``close()``.
* After the first error the worker drops any remaining queued work so
  the main thread cannot deadlock on a full queue.
* Back-pressure: with ``max_pending=1`` and a slow worker, ``submit()``
  blocks once the queue is full.
* The context-manager protocol drains on clean exit and always joins
  the worker thread.
"""

import threading
import time
import unittest

from pytc.utils.gpu_pipeline import _AsyncHDF5Writer


[docs] class TestAsyncHDF5Writer(unittest.TestCase):
[docs] def test_preserves_submit_order(self): results = [] with _AsyncHDF5Writer(max_pending=8) as w: for i in range(16): w.submit(results.append, i) w.drain() self.assertEqual(results, list(range(16)))
[docs] def test_drain_blocks_until_empty(self): lock = threading.Lock() counter = [0] def slow_job(): time.sleep(0.005) with lock: counter[0] += 1 with _AsyncHDF5Writer(max_pending=8) as w: for _ in range(10): w.submit(slow_job) w.drain() # After drain() returns, every submitted job MUST have run. with lock: self.assertEqual(counter[0], 10)
[docs] def test_error_latched_and_reraised_on_next_submit(self): class Boom(RuntimeError): pass def explode(): raise Boom("kapow") w = _AsyncHDF5Writer(max_pending=4) try: w.submit(explode) # Give the worker a moment to latch the error. for _ in range(200): with w._err_lock: # pylint: disable=protected-access if w._err is not None: # pylint: disable=protected-access break time.sleep(0.005) else: self.fail("worker did not latch error within 1s") with self.assertRaises(Boom): w.submit(lambda: None) with self.assertRaises(Boom): w.drain() finally: # close() should also re-raise the latched error once, then # become idempotent and the thread must be joined. with self.assertRaises(Boom): w.close() self.assertFalse(w._thread.is_alive()) # pylint: disable=protected-access
[docs] def test_error_does_not_block_subsequent_submits(self): """After the first failure the worker drops remaining jobs so submit() does not block on a full queue (regression guard for the 'error + full queue + dead worker' deadlock).""" started_second = threading.Event() release_first = threading.Event() def first(): release_first.wait(timeout=2.0) raise RuntimeError("first failed") def second(): started_second.set() w = _AsyncHDF5Writer(max_pending=2) try: w.submit(first) # occupies the worker w.submit(second) # sits in the queue release_first.set() # let first() raise → latches error # Give the worker a moment to drain `second` without running it. for _ in range(200): with w._err_lock: # pylint: disable=protected-access if w._err is not None: # pylint: disable=protected-access break time.sleep(0.005) else: self.fail("error was not latched in time") self.assertFalse(started_second.is_set(), "second job must be dropped after first failure") finally: with self.assertRaises(RuntimeError): w.close()
[docs] def test_backpressure_blocks_when_queue_full(self): """With max_pending=1 and a blocked worker, the 2nd submit must wait for the worker to free a slot.""" gate = threading.Event() def blocked(): gate.wait(timeout=2.0) with _AsyncHDF5Writer(max_pending=1) as w: # First submit occupies the worker and pops the queue to 0. w.submit(blocked) # Second submit occupies the 1-deep queue. w.submit(lambda: None) submitted = threading.Event() def try_third(): w.submit(lambda: None) submitted.set() t = threading.Thread(target=try_third) t.start() # Without a free slot the third submit must block. self.assertFalse( submitted.wait(timeout=0.2), "submit should block when queue is full") gate.set() # let blocked() return → worker frees a slot self.assertTrue( submitted.wait(timeout=2.0), "submit should unblock once the worker drains a slot") t.join() w.drain()
[docs] def test_context_manager_drains_on_clean_exit(self): results = [] with _AsyncHDF5Writer(max_pending=4) as w: for i in range(5): w.submit(results.append, i) # Intentionally do NOT call drain() — __exit__ should do it. self.assertEqual(results, [0, 1, 2, 3, 4]) # Worker thread should be joined. self.assertFalse(w._thread.is_alive()) # pylint: disable=protected-access
[docs] def test_context_manager_joins_thread_on_exception(self): """If the with-block body raises, __exit__ must still join the worker thread (no leaked daemon) and must not swallow the original exception.""" class UserError(ValueError): pass w = None with self.assertRaises(UserError): with _AsyncHDF5Writer(max_pending=2) as w: w.submit(lambda: None) raise UserError("bang") self.assertIsNotNone(w) self.assertFalse(w._thread.is_alive()) # pylint: disable=protected-access
[docs] def test_submit_after_close_raises(self): w = _AsyncHDF5Writer(max_pending=2) w.close() with self.assertRaises(RuntimeError): w.submit(lambda: None)
[docs] def test_invalid_max_pending_rejected(self): with self.assertRaises(ValueError): _AsyncHDF5Writer(max_pending=0)
[docs] def test_stats_tracks_busy_time_bytes_and_saturation(self): """``stats()`` must report reasonable busy/active/bytes totals. Uses a sleep-based fake job so busy_time is large enough to compare against real wall time without flakiness. """ def fake_write(payload): # Simulate ~30ms of HDF5 work; we don't actually touch # a file — the writer doesn't care what fn() does. time.sleep(0.03) with _AsyncHDF5Writer(max_pending=4, name="test-writer") as w: for i in range(5): w.submit(fake_write, f"chunk-{i}", _bytes=1_000_000) w.drain() s = w.stats() self.assertEqual(s["n_jobs"], 5) self.assertEqual(s["bytes_written"], 5 * 1_000_000) # Each job slept at least ~30ms. Loaded CI hosts may oversleep, # so only the lower bound is a portable correctness invariant. self.assertGreater(s["busy_time_s"], 0.10) # Single-threaded serial execution → saturation near 1.0. self.assertGreater(s["saturation"], 0.80) self.assertLessEqual(s["saturation"], 1.0 + 1e-9) # Bandwidth numbers should be positive and finite. self.assertGreater(s["busy_throughput_GBps"], 0.0) self.assertGreater(s["effective_throughput_GBps"], 0.0) # And busy_BW should be >= effective_BW by construction. self.assertGreaterEqual( s["busy_throughput_GBps"] + 1e-9, s["effective_throughput_GBps"], )
[docs] def test_stats_bytes_zero_when_hint_omitted(self): with _AsyncHDF5Writer(max_pending=4) as w: for _ in range(3): w.submit(lambda: None) w.drain() s = w.stats() self.assertEqual(s["n_jobs"], 3) self.assertEqual(s["bytes_written"], 0) self.assertEqual(s["busy_throughput_GBps"], 0.0) self.assertEqual(s["effective_throughput_GBps"], 0.0)
[docs] def test_log_summary_noop_before_any_job(self): """log_summary must emit nothing if n_jobs == 0 (don't spam callers with meaningless zeros at shutdown of an unused writer).""" calls = [] w = _AsyncHDF5Writer(max_pending=2) try: w.log_summary(lambda *a, **k: calls.append(a)) self.assertEqual(calls, []) finally: w.close()
[docs] def test_bytes_kwarg_is_stripped_before_calling_fn(self): """The ``_bytes`` hint is consumed by the writer and must not be forwarded to the user's callable.""" received = {} def record(**kwargs): received.update(kwargs) with _AsyncHDF5Writer(max_pending=2) as w: w.submit(record, foo=1, _bytes=42) w.drain() self.assertEqual(received, {"foo": 1}) # The writer saw the size hint though: with _AsyncHDF5Writer(max_pending=2) as w: w.submit(record, _bytes=99) w.drain() self.assertEqual(w.stats()["bytes_written"], 99)
if __name__ == "__main__": unittest.main()