test_inference.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. """Tests for the inference queue (Phase B)."""
  2. import threading
  3. import time
  4. from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
  5. def _wait_in_flight(runner: "_JobRunner", timeout: float = 1.0) -> None:
  6. """Block until a job is executing (or fail the caller's timeout)."""
  7. deadline = time.monotonic() + timeout
  8. while not runner.in_flight():
  9. assert time.monotonic() < deadline, "no job started in time"
  10. time.sleep(0.001)
  11. def _wait_idle(queue: InferenceQueue, timeout: float = 2.0) -> None:
  12. """Block until nothing is queued and no job is in-flight."""
  13. deadline = time.monotonic() + timeout
  14. while queue.depth() > 0 or queue.in_flight():
  15. assert time.monotonic() < deadline, "queue did not drain in time"
  16. time.sleep(0.001)
  17. class _JobRunner:
  18. """Helper to track job execution timing and detect concurrency."""
  19. def __init__(self):
  20. self.events: list[tuple[str, str]] = [] # [(event, job_name), ...]
  21. self.lock = threading.Lock()
  22. def job(self, name: str, block_until: threading.Event | None = None):
  23. """Return a callable that records start/end and optionally blocks."""
  24. def run():
  25. with self.lock:
  26. self.events.append(("start", name))
  27. if block_until is not None:
  28. block_until.wait()
  29. with self.lock:
  30. self.events.append(("end", name))
  31. return run
  32. def in_flight(self) -> str | None:
  33. """Return the name of the job currently running, or None."""
  34. with self.lock:
  35. for i in range(len(self.events) - 1, -1, -1):
  36. event, name = self.events[i]
  37. if event == "start":
  38. return name
  39. if event == "end":
  40. return None
  41. return None
  42. def test_jobs_run_one_at_a_time():
  43. """Max concurrency is 1: job 2 does not start until job 1 ends."""
  44. queue = InferenceQueue(size=4)
  45. runner = _JobRunner()
  46. unblock = threading.Event()
  47. queue.start()
  48. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  49. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  50. queue.submit(job1)
  51. queue.submit(job2)
  52. # Give job1 time to start
  53. import time
  54. time.sleep(0.01)
  55. # job2 must not have started yet
  56. assert runner.in_flight() == "job1"
  57. # Unblock job1
  58. unblock.set()
  59. time.sleep(0.05)
  60. # Now job2 should be running or done
  61. assert any(event[1] == "job2" for event in runner.events)
  62. queue.stop()
  63. def test_priority_jobs_inserted_at_front():
  64. """Priority job (priority < 0) submitted while normal job queued runs next."""
  65. queue = InferenceQueue(size=4)
  66. runner = _JobRunner()
  67. unblock1 = threading.Event()
  68. unblock_cmd = threading.Event()
  69. queue.start()
  70. job1 = InferenceJob(name="normal", run=runner.job("normal", unblock1), priority=0)
  71. job_cmd = InferenceJob(name="command", run=runner.job("command", unblock_cmd), priority=-1)
  72. job3 = InferenceJob(name="chat2", run=runner.job("chat2"), priority=0)
  73. queue.submit(job1)
  74. queue.submit(job3)
  75. queue.submit(job_cmd)
  76. import time
  77. time.sleep(0.1)
  78. # Release all blocks to complete all jobs
  79. unblock1.set()
  80. unblock_cmd.set()
  81. time.sleep(0.1)
  82. # Check that command ran before chat2 (priority ordering)
  83. names = [name for _, name in runner.events if _ == "end"]
  84. assert "command" in names
  85. assert "chat2" in names
  86. assert "normal" in names
  87. # Priority guarantees: command should run before chat2
  88. cmd_index = names.index("command")
  89. chat_index = names.index("chat2")
  90. assert cmd_index < chat_index
  91. queue.stop()
  92. def test_priority_job_rejected_when_queue_full():
  93. """Overflow: priority job is rejected (submit returns False)."""
  94. queue = InferenceQueue(size=2)
  95. runner = _JobRunner()
  96. unblock = threading.Event()
  97. queue.start()
  98. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock), priority=0)
  99. job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
  100. job_cmd = InferenceJob(name="command", run=runner.job("command"), priority=-1)
  101. assert queue.submit(job1) is True
  102. assert queue.submit(job2) is True
  103. # Queue is now full (size=2: job1 in-flight, job2 queued)
  104. assert queue.submit(job_cmd) is False
  105. queue.stop()
  106. def test_normal_job_evicts_oldest_on_overflow():
  107. """Overflow: normal job drops the oldest normal job."""
  108. queue = InferenceQueue(size=2)
  109. runner = _JobRunner()
  110. unblock = threading.Event()
  111. queue.start()
  112. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock), priority=0)
  113. job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
  114. job3 = InferenceJob(name="job3", run=runner.job("job3"), priority=0)
  115. assert queue.submit(job1) is True
  116. import time
  117. time.sleep(0.05)
  118. # Ensure job1 is in-flight
  119. while not runner.in_flight():
  120. time.sleep(0.001)
  121. # Now queue is: job1 in-flight, job2 queued, room for 1 more
  122. assert queue.submit(job2) is True
  123. # Queue is now full (in-flight + 1 queued)
  124. # job3 submits, evicts job2 (oldest queued normal job)
  125. assert queue.submit(job3) is True
  126. unblock.set()
  127. time.sleep(0.1)
  128. # Only job1 and job3 should have run; job2 was evicted
  129. names = [name for _, name in runner.events if _ == "end"]
  130. assert "job2" not in names
  131. assert set(names) == {"job1", "job3"}
  132. queue.stop()
  133. def test_drop_removes_matching_queued_jobs():
  134. """drop(predicate) removes queued jobs matching predicate, returns count."""
  135. queue = InferenceQueue(size=8)
  136. runner = _JobRunner()
  137. unblock = threading.Event()
  138. queue.start()
  139. job1 = InferenceJob(name="chat", run=runner.job("chat1", unblock), priority=0)
  140. job2 = InferenceJob(name="chat", run=runner.job("chat2"), priority=0)
  141. job3 = InferenceJob(name="cmd", run=runner.job("cmd"), priority=-1)
  142. queue.submit(job1)
  143. queue.submit(job2)
  144. queue.submit(job3)
  145. import time
  146. time.sleep(0.05)
  147. # Give time for job1 to start in-flight
  148. while not runner.in_flight():
  149. time.sleep(0.001)
  150. # Drop all chat jobs; job1 is in-flight, job2 is queued
  151. count = queue.drop(lambda j: j.name == "chat")
  152. assert count == 1 # job2 is queued; job1 is in-flight and not dropped
  153. unblock.set()
  154. time.sleep(0.1)
  155. # Only job1 (in-flight, not dropped) and job3 should run
  156. names = [name for _, name in runner.events if _ == "end"]
  157. assert set(names) == {"chat1", "cmd"}
  158. queue.stop()
  159. def test_stop_discards_queued_and_joins():
  160. """stop() discards queued jobs, lets in-flight finish, then joins."""
  161. queue = InferenceQueue(size=4)
  162. runner = _JobRunner()
  163. unblock = threading.Event()
  164. queue.start()
  165. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  166. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  167. job3 = InferenceJob(name="job3", run=runner.job("job3"))
  168. queue.submit(job1)
  169. queue.submit(job2)
  170. queue.submit(job3)
  171. import time
  172. time.sleep(0.01)
  173. queue.stop()
  174. # job1 was in-flight, so let it finish
  175. unblock.set()
  176. time.sleep(0.05)
  177. # Only job1 should have run; job2 and job3 were discarded
  178. names = [name for _, name in runner.events if _ == "end"]
  179. assert names == ["job1"]
  180. def test_depth_reflects_queued_count():
  181. """depth() returns the number of queued (not in-flight) jobs."""
  182. queue = InferenceQueue(size=8)
  183. runner = _JobRunner()
  184. unblock = threading.Event()
  185. queue.start()
  186. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  187. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  188. job3 = InferenceJob(name="job3", run=runner.job("job3"))
  189. queue.submit(job1)
  190. import time
  191. time.sleep(0.05)
  192. # Give worker time to dequeue job1
  193. while queue.depth() > 0 and not runner.in_flight():
  194. time.sleep(0.001)
  195. queue.submit(job2)
  196. queue.submit(job3)
  197. # job1 is in-flight, job2 and job3 are queued
  198. assert queue.depth() >= 1
  199. unblock.set()
  200. time.sleep(0.1)
  201. # All jobs should finish; no more queued
  202. assert queue.depth() == 0
  203. queue.stop()
  204. def test_no_submit_after_stop():
  205. """submit() returns False after stop()."""
  206. queue = InferenceQueue(size=4)
  207. queue.start()
  208. queue.stop()
  209. job = InferenceJob(name="job", run=lambda: None)
  210. assert queue.submit(job) is False
  211. def test_bound_holds_when_only_priority_jobs_are_present():
  212. """A normal job is rejected when there is no normal job to evict."""
  213. queue = InferenceQueue(size=1)
  214. release = threading.Event()
  215. def block():
  216. release.wait()
  217. queue.start()
  218. try:
  219. assert queue.submit(InferenceJob(name="cmd", run=block, priority=-1)) is True
  220. assert queue.submit(InferenceJob(name="chat", run=lambda: None)) is False
  221. finally:
  222. release.set()
  223. queue.stop()
  224. def test_in_flight_true_during_job_execution():
  225. """in_flight() returns True while a job is executing, False after."""
  226. queue = InferenceQueue(size=4)
  227. runner = _JobRunner()
  228. unblock = threading.Event()
  229. queue.start()
  230. job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
  231. queue.submit(job1)
  232. import time
  233. time.sleep(0.01)
  234. # job1 is in-flight
  235. assert queue.in_flight() is True
  236. assert queue.depth() == 0
  237. unblock.set()
  238. time.sleep(0.05)
  239. # job1 finished
  240. assert queue.in_flight() is False
  241. queue.stop()
  242. def test_submit_if_room_refuses_when_full_without_eviction():
  243. """1 in-flight + 3 queued at size=4 (reserve=1) refuses and mutates nothing."""
  244. queue = InferenceQueue(size=4)
  245. runner = _JobRunner()
  246. unblock = threading.Event()
  247. queue.start()
  248. try:
  249. job1 = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock))
  250. assert queue.submit(job1) is True
  251. _wait_in_flight(runner)
  252. job2 = InferenceJob(name="job2", run=runner.job("job2"))
  253. job3 = InferenceJob(name="job3", run=runner.job("job3"))
  254. job4 = InferenceJob(name="job4", run=runner.job("job4"))
  255. assert queue.submit(job2) is True
  256. assert queue.submit(job3) is True
  257. assert queue.submit(job4) is True
  258. # 1 in-flight + 3 queued == size; reserve=1 caps admission at 3.
  259. before = [id(job) for job in queue._queue]
  260. assert len(before) == 3
  261. assert queue.submit_if_room(InferenceJob(name="extract", run=lambda: None)) is False
  262. after = [id(job) for job in queue._queue]
  263. assert after == before
  264. assert queue.depth() == 3
  265. finally:
  266. unblock.set()
  267. queue.stop()
  268. def test_submit_if_room_admits_and_runs_when_room():
  269. """With room, submit_if_room appends the job and the worker runs it."""
  270. queue = InferenceQueue(size=4)
  271. ran = threading.Event()
  272. queue.start()
  273. try:
  274. job = InferenceJob(name="extract", run=ran.set)
  275. assert queue.submit_if_room(job) is True
  276. assert ran.wait(timeout=1.0) is True
  277. finally:
  278. queue.stop()
  279. assert queue.depth() == 0
  280. def test_submit_if_room_after_stop_refuses_and_enqueues_nothing():
  281. """After stop(), submit_if_room refuses and leaves the queue empty."""
  282. queue = InferenceQueue(size=4)
  283. queue.start()
  284. queue.stop()
  285. job = InferenceJob(name="extract", run=lambda: None)
  286. assert queue.submit_if_room(job) is False
  287. assert queue.depth() == 0
  288. def test_submit_if_room_reserve_larger_than_size_always_refuses():
  289. """A reserve >= size refuses without crashing, even on an empty queue."""
  290. queue = InferenceQueue(size=2)
  291. queue.start()
  292. try:
  293. assert queue.submit_if_room(InferenceJob(name="j", run=lambda: None), reserve=5) is False
  294. assert queue.depth() == 0
  295. finally:
  296. queue.stop()
  297. def test_submit_still_evicts_oldest_normal_while_submit_if_room_refuses():
  298. """submit() keeps its drop-oldest behavior where submit_if_room refuses."""
  299. queue = InferenceQueue(size=3)
  300. runner = _JobRunner()
  301. unblock = threading.Event()
  302. queue.start()
  303. try:
  304. job1 = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
  305. assert queue.submit(job1) is True
  306. _wait_in_flight(runner)
  307. job2 = InferenceJob(name="job2", run=runner.job("job2"), priority=0)
  308. job3 = InferenceJob(name="job3", run=runner.job("job3"), priority=0)
  309. assert queue.submit(job2) is True
  310. assert queue.submit(job3) is True
  311. # 1 in-flight + 2 queued == size; reserve=1 caps admission at 2.
  312. refused = InferenceJob(name="extract", run=lambda: None)
  313. assert queue.submit_if_room(refused) is False
  314. assert all(job is not refused for job in queue._queue)
  315. # submit() still evicts the oldest normal queued job (job2).
  316. job4 = InferenceJob(name="job4", run=lambda: None)
  317. assert queue.submit(job4) is True
  318. remaining_names = [job.name for job in queue._queue]
  319. assert remaining_names == ["job3", "job4"]
  320. finally:
  321. unblock.set()
  322. queue.stop()
  323. def test_submit_if_room_concurrent_with_draining_worker_loses_no_job():
  324. """Admission from a producer thread racing the worker never raises or loses accepted jobs."""
  325. queue = InferenceQueue(size=8)
  326. accepted: list[str] = []
  327. executed: set[str] = set()
  328. errors: list[Exception] = []
  329. guard = threading.Lock()
  330. def make_run(name: str):
  331. def run():
  332. with guard:
  333. executed.add(name)
  334. return run
  335. def producer():
  336. try:
  337. for i in range(500):
  338. name = f"job{i}"
  339. job = InferenceJob(name=name, run=make_run(name))
  340. if queue.submit_if_room(job):
  341. with guard:
  342. accepted.append(name)
  343. except Exception as exc: # noqa: BLE001 - recorded for assertion
  344. errors.append(exc)
  345. queue.start()
  346. try:
  347. thread = threading.Thread(target=producer)
  348. thread.start()
  349. thread.join(timeout=5.0)
  350. assert not thread.is_alive()
  351. _wait_idle(queue)
  352. assert errors == []
  353. assert accepted
  354. assert set(accepted) <= executed
  355. finally:
  356. queue.stop()
  357. def test_stop_during_in_flight_is_bounded_then_refuses_admission():
  358. """stop() returns within its join bound while a job blocks; admission stays refused."""
  359. queue = InferenceQueue(size=4)
  360. started = threading.Event()
  361. release = threading.Event()
  362. def block():
  363. started.set()
  364. release.wait()
  365. queue.start()
  366. assert queue.submit(InferenceJob(name="long", run=block)) is True
  367. assert started.wait(timeout=1.0) is True
  368. begin = time.monotonic()
  369. queue.stop()
  370. elapsed = time.monotonic() - begin
  371. assert elapsed < 1.5
  372. assert queue.submit_if_room(InferenceJob(name="extract", run=lambda: None)) is False
  373. assert queue.depth() == 0
  374. release.set()
  375. def test_non_evictable_normal_job_survives_overflow():
  376. """Only non-evictable normal jobs queued: overflow is refused, nothing removed."""
  377. queue = InferenceQueue(size=2)
  378. runner = _JobRunner()
  379. unblock = threading.Event()
  380. queue.start()
  381. try:
  382. blocker = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
  383. assert queue.submit(blocker) is True
  384. _wait_in_flight(runner)
  385. keep = InferenceJob(name="brief", run=lambda: None, priority=0, evictable=False)
  386. assert queue.submit(keep) is True
  387. # 1 in-flight + 1 non-evictable queued == size; no evictable job exists.
  388. refused = InferenceJob(name="chat", run=lambda: None, priority=0, evictable=True)
  389. assert queue.submit(refused) is False
  390. assert [job.name for job in queue._queue] == ["brief"]
  391. assert all(job is not refused for job in queue._queue)
  392. finally:
  393. unblock.set()
  394. queue.stop()
  395. def test_overflow_evicts_oldest_evictable_not_non_evictable():
  396. """Mixed queue: overflow drops the oldest evictable, keeps the non-evictable and newest."""
  397. queue = InferenceQueue(size=3)
  398. runner = _JobRunner()
  399. unblock = threading.Event()
  400. queue.start()
  401. try:
  402. blocker = InferenceJob(name="in-flight", run=runner.job("in-flight", unblock), priority=0)
  403. assert queue.submit(blocker) is True
  404. _wait_in_flight(runner)
  405. keep = InferenceJob(name="brief", run=lambda: None, priority=0, evictable=False)
  406. old = InferenceJob(name="old-chat", run=lambda: None, priority=0, evictable=True)
  407. assert queue.submit(keep) is True
  408. assert queue.submit(old) is True
  409. # 1 in-flight + 2 queued == size. The new normal job must evict `old`,
  410. # skip the non-evictable `keep`, and stay queued itself.
  411. new = InferenceJob(name="new-chat", run=lambda: None, priority=0, evictable=True)
  412. assert queue.submit(new) is True
  413. assert [job.name for job in queue._queue] == ["brief", "new-chat"]
  414. assert all(job is not old for job in queue._queue)
  415. finally:
  416. unblock.set()
  417. queue.stop()