test_inference.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Tests for the inference queue (Phase B)."""
  2. import threading
  3. from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
  4. class _JobRunner:
  5. """Helper to track job execution timing and detect concurrency."""
  6. def __init__(self):
  7. self.events: list[tuple[str, str]] = [] # [(event, job_name), ...]
  8. self.lock = threading.Lock()
  9. def job(self, name: str, block_until: threading.Event | None = None):
  10. """Return a callable that records start/end and optionally blocks."""
  11. def run():
  12. with self.lock:
  13. self.events.append(("start", name))
  14. if block_until is not None:
  15. block_until.wait()
  16. with self.lock:
  17. self.events.append(("end", name))
  18. return run
  19. def in_flight(self) -> str | None:
  20. """Return the name of the job currently running, or None."""
  21. with self.lock:
  22. for i in range(len(self.events) - 1, -1, -1):
  23. event, name = self.events[i]
  24. if event == "start":
  25. return name
  26. if event == "end":
  27. return None
  28. return None
  29. def test_jobs_run_one_at_a_time():
  30. """Max concurrency is 1: job 2 does not start until job 1 ends."""
  31. queue = InferenceQueue(size=4)
  32. runner = _JobRunner()
  33. unblock = threading.Event()
  34. queue.start()
  35. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  36. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  37. queue.submit(job1)
  38. queue.submit(job2)
  39. # Give job1 time to start
  40. import time
  41. time.sleep(0.01)
  42. # job2 must not have started yet
  43. assert runner.in_flight() == "job1"
  44. # Unblock job1
  45. unblock.set()
  46. time.sleep(0.05)
  47. # Now job2 should be running or done
  48. assert any(event[1] == "job2" for event in runner.events)
  49. queue.stop()
  50. def test_priority_jobs_inserted_at_front():
  51. """Priority job (priority < 0) submitted while normal job queued runs next."""
  52. queue = InferenceQueue(size=4)
  53. runner = _JobRunner()
  54. unblock1 = threading.Event()
  55. unblock_cmd = threading.Event()
  56. queue.start()
  57. job1 = InferenceJob(name="normal", run=runner.job("normal", unblock1), priority=0)
  58. job_cmd = InferenceJob(name="command", run=runner.job("command", unblock_cmd), priority=-1)
  59. job3 = InferenceJob(name="chat2", run=runner.job("chat2"), priority=0)
  60. queue.submit(job1)
  61. queue.submit(job3)
  62. queue.submit(job_cmd)
  63. import time
  64. time.sleep(0.1)
  65. # Release all blocks to complete all jobs
  66. unblock1.set()
  67. unblock_cmd.set()
  68. time.sleep(0.1)
  69. # Check that command ran before chat2 (priority ordering)
  70. names = [name for _, name in runner.events if _ == "end"]
  71. assert "command" in names
  72. assert "chat2" in names
  73. assert "normal" in names
  74. # Priority guarantees: command should run before chat2
  75. cmd_index = names.index("command")
  76. chat_index = names.index("chat2")
  77. assert cmd_index < chat_index
  78. queue.stop()
  79. def test_priority_job_rejected_when_queue_full():
  80. """Overflow: priority job is rejected (submit returns False)."""
  81. queue = InferenceQueue(size=2)
  82. runner = _JobRunner()
  83. unblock = threading.Event()
  84. queue.start()
  85. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock), priority=0)
  86. job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
  87. job_cmd = InferenceJob(name="command", run=runner.job("command"), priority=-1)
  88. assert queue.submit(job1) is True
  89. assert queue.submit(job2) is True
  90. # Queue is now full (size=2: job1 in-flight, job2 queued)
  91. assert queue.submit(job_cmd) is False
  92. queue.stop()
  93. def test_normal_job_evicts_oldest_on_overflow():
  94. """Overflow: normal job drops the oldest normal job."""
  95. queue = InferenceQueue(size=2)
  96. runner = _JobRunner()
  97. unblock = threading.Event()
  98. queue.start()
  99. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock), priority=0)
  100. job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
  101. job3 = InferenceJob(name="job3", run=runner.job("job3"), priority=0)
  102. assert queue.submit(job1) is True
  103. import time
  104. time.sleep(0.05)
  105. # Ensure job1 is in-flight
  106. while not runner.in_flight():
  107. time.sleep(0.001)
  108. # Now queue is: job1 in-flight, job2 queued, room for 1 more
  109. assert queue.submit(job2) is True
  110. # Queue is now full (in-flight + 1 queued)
  111. # job3 submits, evicts job2 (oldest queued normal job)
  112. assert queue.submit(job3) is True
  113. unblock.set()
  114. time.sleep(0.1)
  115. # Only job1 and job3 should have run; job2 was evicted
  116. names = [name for _, name in runner.events if _ == "end"]
  117. assert "job2" not in names
  118. assert set(names) == {"job1", "job3"}
  119. queue.stop()
  120. def test_drop_removes_matching_queued_jobs():
  121. """drop(predicate) removes queued jobs matching predicate, returns count."""
  122. queue = InferenceQueue(size=8)
  123. runner = _JobRunner()
  124. unblock = threading.Event()
  125. queue.start()
  126. job1 = InferenceJob(name="chat", run=runner.job("chat1", unblock), priority=0)
  127. job2 = InferenceJob(name="chat", run=runner.job("chat2"), priority=0)
  128. job3 = InferenceJob(name="cmd", run=runner.job("cmd"), priority=-1)
  129. queue.submit(job1)
  130. queue.submit(job2)
  131. queue.submit(job3)
  132. import time
  133. time.sleep(0.05)
  134. # Give time for job1 to start in-flight
  135. while not runner.in_flight():
  136. time.sleep(0.001)
  137. # Drop all chat jobs; job1 is in-flight, job2 is queued
  138. count = queue.drop(lambda j: j.name == "chat")
  139. assert count == 1 # job2 is queued; job1 is in-flight and not dropped
  140. unblock.set()
  141. time.sleep(0.1)
  142. # Only job1 (in-flight, not dropped) and job3 should run
  143. names = [name for _, name in runner.events if _ == "end"]
  144. assert set(names) == {"chat1", "cmd"}
  145. queue.stop()
  146. def test_stop_discards_queued_and_joins():
  147. """stop() discards queued jobs, lets in-flight finish, then joins."""
  148. queue = InferenceQueue(size=4)
  149. runner = _JobRunner()
  150. unblock = threading.Event()
  151. queue.start()
  152. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  153. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  154. job3 = InferenceJob(name="job3", run=runner.job("job3"))
  155. queue.submit(job1)
  156. queue.submit(job2)
  157. queue.submit(job3)
  158. import time
  159. time.sleep(0.01)
  160. queue.stop()
  161. # job1 was in-flight, so let it finish
  162. unblock.set()
  163. time.sleep(0.05)
  164. # Only job1 should have run; job2 and job3 were discarded
  165. names = [name for _, name in runner.events if _ == "end"]
  166. assert names == ["job1"]
  167. def test_depth_reflects_queued_count():
  168. """depth() returns the number of queued (not in-flight) jobs."""
  169. queue = InferenceQueue(size=8)
  170. runner = _JobRunner()
  171. unblock = threading.Event()
  172. queue.start()
  173. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  174. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  175. job3 = InferenceJob(name="job3", run=runner.job("job3"))
  176. queue.submit(job1)
  177. import time
  178. time.sleep(0.05)
  179. # Give worker time to dequeue job1
  180. while queue.depth() > 0 and not runner.in_flight():
  181. time.sleep(0.001)
  182. queue.submit(job2)
  183. queue.submit(job3)
  184. # job1 is in-flight, job2 and job3 are queued
  185. assert queue.depth() >= 1
  186. unblock.set()
  187. time.sleep(0.1)
  188. # All jobs should finish; no more queued
  189. assert queue.depth() == 0
  190. queue.stop()
  191. def test_no_submit_after_stop():
  192. """submit() returns False after stop()."""
  193. queue = InferenceQueue(size=4)
  194. queue.start()
  195. queue.stop()
  196. job = InferenceJob(name="job", run=lambda: None)
  197. assert queue.submit(job) is False
  198. def test_bound_holds_when_only_priority_jobs_are_present():
  199. """A normal job is rejected when there is no normal job to evict."""
  200. queue = InferenceQueue(size=1)
  201. release = threading.Event()
  202. def block():
  203. release.wait()
  204. queue.start()
  205. try:
  206. assert queue.submit(InferenceJob(name="cmd", run=block, priority=-1)) is True
  207. assert queue.submit(InferenceJob(name="chat", run=lambda: None)) is False
  208. finally:
  209. release.set()
  210. queue.stop()