File size: 13,735 Bytes
da2aa92
36333c5
 
 
 
 
 
eb808a5
36333c5
 
 
da2aa92
36333c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c30cfb
36333c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f730852
da2aa92
 
 
36333c5
 
 
 
 
eb808a5
 
0c30cfb
63021e7
 
 
0c30cfb
f730852
63021e7
 
 
0c30cfb
63021e7
 
f730852
63021e7
451a0fc
f730852
63021e7
 
 
 
0c30cfb
 
da2aa92
63021e7
 
 
f730852
 
 
da2aa92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
da2aa92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb808a5
 
 
 
 
 
 
 
 
 
 
c3ab86e
 
da2aa92
 
 
 
 
c3ab86e
da2aa92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3ab86e
 
da2aa92
c3ab86e
 
da2aa92
 
c3ab86e
da2aa92
c3ab86e
da2aa92
 
 
 
 
 
 
451a0fc
da2aa92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3ab86e
 
da2aa92
451a0fc
c3ab86e
da2aa92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2102d60
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
2102d60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05d012f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
38074f6
 
05d012f
 
 
 
 
 
 
 
 
 
 
 
 
e965fc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
e965fc7
 
 
 
 
 
 
 
 
 
 
 
b6d3ae2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
b6d3ae2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd12235
 
 
 
 
 
 
 
1fe492d
bd12235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4fac3e
 
 
 
 
 
 
 
 
 
 
 
 
451a0fc
c4fac3e
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
"""Concurrency and lifecycle guarantees for the inference queue."""

from __future__ import annotations

import asyncio
import threading
import time
from contextvars import ContextVar

import pytest

from core.errors import GatewayError, QueueCapacityError
from core.queue import InferenceQueue


async def test_queue_executes_only_one_operation_at_a_time() -> None:
    queue = InferenceQueue(capacity=4, timeout_seconds=5)
    active = 0
    maximum_active = 0
    lock = threading.Lock()

    def operation(value: int) -> int:
        nonlocal active, maximum_active
        with lock:
            active += 1
            maximum_active = max(maximum_active, active)
        time.sleep(0.02)
        with lock:
            active -= 1
        return value

    results = await asyncio.gather(
        *(queue.submit(str(index), "test", lambda index=index: operation(index)) for index in range(3))
    )
    await queue.stop()

    assert results == [0, 1, 2]
    assert maximum_active == 1


async def test_queue_timeout_cancels_job_before_inference() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=0.01)
    queued_job_ran = False

    async def submit_first() -> None:
        await queue.submit("first", "slow", lambda: time.sleep(0.04))

    def queued_operation() -> None:
        nonlocal queued_job_ran
        queued_job_ran = True

    first = asyncio.create_task(submit_first())
    await asyncio.sleep(0.005)

    with pytest.raises(GatewayError, match="timed out") as captured:
        await queue.submit("second", "queued", queued_operation)

    assert captured.value.status_code == 504
    assert captured.value.code == "queue_timeout"
    record = queue.job("second")
    assert record is not None
    assert record["status"] == "timeout"
    await first
    await asyncio.sleep(0)
    assert queued_job_ran is False
    assert queue.active is False
    await queue.stop()


async def test_cancellation_waits_for_started_operation() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1)
    started = threading.Event()
    finished = threading.Event()
    output = []

    def operation() -> int:
        started.set()
        time.sleep(0.04)
        output.append("complete")
        finished.set()
        return 7

    task = asyncio.create_task(queue.submit("request", "slow", operation))
    assert await asyncio.to_thread(started.wait, 0.1)

    task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await task

    assert finished.is_set()
    assert output == ["complete"]
    assert queue.job("request")["status"] == "succeeded"

    result = await queue.submit("next", "test", lambda: 42)
    assert result == 42
    await queue.stop()


async def test_cancellation_before_start_marks_job_cancelled_and_does_not_run() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1)
    blocker_started = threading.Event()
    blocker_finished = threading.Event()
    queued_ran = False

    def blocker() -> None:
        blocker_started.set()
        time.sleep(0.05)
        blocker_finished.set()

    def queued_operation() -> None:
        nonlocal queued_ran
        queued_ran = True

    first = asyncio.create_task(queue.submit("blocker", "slow", blocker))
    assert await asyncio.to_thread(blocker_started.wait, 0.1)

    second = asyncio.create_task(queue.submit("cancelled", "queued", queued_operation))
    await asyncio.sleep(0.005)
    second.cancel()

    with pytest.raises(asyncio.CancelledError):
        await second
    await first

    assert blocker_finished.is_set()
    assert queued_ran is False
    record = queue.job("cancelled")
    assert record is not None
    assert record["status"] == "cancelled"
    assert record["finished_at"] is not None
    await queue.stop()


async def test_queue_preserves_request_context_for_worker() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=5)
    request_context: ContextVar[str] = ContextVar("test_request", default="missing")
    token = request_context.set("request-identity")
    try:
        result = await queue.submit("request", "context", request_context.get)
    finally:
        request_context.reset(token)
        await queue.stop()

    assert result == "request-identity"


async def test_duplicate_request_id_is_rejected_without_overwriting_history() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1, history_size=10)

    assert await queue.submit("duplicate", "first", lambda: 1) == 1

    with pytest.raises(GatewayError) as captured:
        await queue.submit("duplicate", "second", lambda: 2)

    assert captured.value.status_code == 409
    assert captured.value.code == "request_id_conflict"
    record = queue.job("duplicate")
    assert record is not None
    assert record["label"] == "first"
    assert record["status"] == "succeeded"
    await queue.stop()


async def test_queue_history_is_bounded_and_newest_first() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1, history_size=2)

    await queue.submit("one", "first", lambda: 1)
    await queue.submit("two", "second", lambda: 2)
    await queue.submit("three", "third", lambda: 3)

    records = queue.jobs()
    assert [record["request_id"] for record in records] == ["three", "two"]
    assert queue.job("one") is None
    await queue.stop()


async def test_operation_failure_is_recorded() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1)

    def fail() -> None:
        raise RuntimeError("model failed")

    with pytest.raises(RuntimeError, match="model failed"):
        await queue.submit("failed", "failure", fail)

    record = queue.job("failed")
    assert record is not None
    assert record["status"] == "failed"
    assert record["error_code"] == "inference_failed"
    assert record["error_message"] == "model failed"
    assert record["finished_at"] is not None
    await queue.stop()


async def test_queue_full_rejects_without_running_the_rejected_operation() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()
    rejected_ran = False

    def blocker() -> None:
        started.set()
        release.wait(0.2)

    def rejected_operation() -> None:
        nonlocal rejected_ran
        rejected_ran = True

    first = asyncio.create_task(queue.submit("running", "blocker", blocker))
    assert await asyncio.to_thread(started.wait, 0.1)

    queued = asyncio.create_task(queue.submit("queued", "waiting", lambda: 2))
    await asyncio.sleep(0.005)

    with pytest.raises(QueueCapacityError):
        await queue.submit("rejected", "overflow", rejected_operation)

    rejected_record = queue.job("rejected")
    assert rejected_record is not None
    assert rejected_record["status"] == "rejected"
    assert rejected_record["error_code"] == "queue_full"

    release.set()
    assert await first is None
    assert await queued == 2
    assert rejected_ran is False
    await queue.stop()


async def test_shutdown_cancels_queued_jobs_but_waits_for_active_operation() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def active_operation() -> int:
        started.set()
        release.wait(0.2)
        return 1

    active_task = asyncio.create_task(queue.submit("active", "active", active_operation))
    assert await asyncio.to_thread(started.wait, 0.1)

    queued_task = asyncio.create_task(queue.submit("queued", "queued", lambda: 2))
    await asyncio.sleep(0.005)

    stop_task = asyncio.create_task(queue.stop(timeout_seconds=0.5))
    await asyncio.sleep(0.005)

    queued_record = queue.job("queued")
    assert queued_record is not None
    assert queued_record["status"] == "cancelled"

    release.set()
    assert await active_task == 1
    assert await stop_task is True

    with pytest.raises(asyncio.CancelledError):
        await queued_task


async def test_shutdown_timeout_resolves_active_request_and_records_timeout() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def active_operation() -> int:
        started.set()
        release.wait(0.5)
        return 9

    submit_task = asyncio.create_task(queue.submit("shutdown-active", "slow", active_operation))
    assert await asyncio.to_thread(started.wait, 0.1)

    assert await queue.stop(timeout_seconds=0.01) is False

    with pytest.raises(GatewayError) as captured:
        await asyncio.wait_for(submit_task, timeout=0.2)

    assert captured.value.status_code == 503
    assert captured.value.code == "shutdown_timeout"
    record = queue.job("shutdown-active")
    assert record is not None
    assert record["status"] == "timeout"
    assert record["error_code"] == "shutdown_timeout"
    assert record["finished_at"] is not None

    # The Python/native worker thread is not force-killed by asyncio cancellation.
    release.set()



async def test_cancellation_remains_primary_when_started_operation_fails() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def fail_after_release() -> int:
        started.set()
        release.wait(0.5)
        raise RuntimeError("model failed after cancellation")

    task = asyncio.create_task(
        queue.submit("cancel-then-fail", "failure", fail_after_release)
    )
    assert await asyncio.to_thread(started.wait, 0.1)
    # Let submit resume from the start signal and enter its result wait.
    await asyncio.sleep(0.01)

    task.cancel()
    release.set()
    with pytest.raises(asyncio.CancelledError):
        await task

    record = queue.job("cancel-then-fail")
    assert record is not None
    assert record["status"] == "failed"
    assert record["error_code"] == "inference_failed"
    assert record["error_message"] == "model failed after cancellation"
    assert record["finished_at"] is not None
    await queue.stop()



async def test_shutdown_timeout_remains_failed_on_repeated_stop() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def active_operation() -> int:
        started.set()
        release.wait(0.5)
        return 1

    submit_task = asyncio.create_task(
        queue.submit("repeat-stop", "slow", active_operation)
    )
    assert await asyncio.to_thread(started.wait, 0.1)

    assert await queue.stop(timeout_seconds=0.01) is False
    assert await queue.stop(timeout_seconds=0.01) is False

    with pytest.raises(GatewayError) as captured:
        await asyncio.wait_for(submit_task, timeout=0.2)
    assert captured.value.code == "shutdown_timeout"

    record = queue.job("repeat-stop")
    assert record is not None
    assert record["status"] == "timeout"
    release.set()



async def test_cancelled_caller_during_shutdown_timeout_keeps_consistent_state() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def active_operation() -> int:
        started.set()
        release.wait(0.5)
        return 5

    task = asyncio.create_task(
        queue.submit("cancel-during-stop", "slow", active_operation)
    )
    assert await asyncio.to_thread(started.wait, 0.1)
    await asyncio.sleep(0.01)

    task.cancel()
    stop_task = asyncio.create_task(queue.stop(timeout_seconds=0.01))
    assert await stop_task is False

    with pytest.raises(asyncio.CancelledError):
        await asyncio.wait_for(task, timeout=0.2)

    record = queue.job("cancel-during-stop")
    assert record is not None
    assert record["status"] == "timeout"
    assert record["error_code"] == "shutdown_timeout"
    assert record["finished_at"] is not None
    release.set()


async def test_submit_racing_shutdown_cannot_enqueue_behind_worker_sentinel() -> None:
    queue = InferenceQueue(capacity=1, timeout_seconds=1)
    entered = asyncio.Event()

    async def submit_job() -> None:
        entered.set()
        await queue.submit("submit-stop-race", "race", lambda: 1)

    # Hold admission while both tasks queue for the lifecycle lock. Whichever
    # acquires it first, shutdown must either cancel the job or reject admission.
    await queue._lifecycle_lock.acquire()
    submit_task = asyncio.create_task(submit_job())
    await entered.wait()
    stop_task = asyncio.create_task(queue.stop(timeout_seconds=0.2))
    await asyncio.sleep(0)
    queue._lifecycle_lock.release()

    stop_result = await asyncio.wait_for(stop_task, timeout=0.5)
    assert stop_result is True
    try:
        await asyncio.wait_for(submit_task, timeout=0.5)
    except (asyncio.CancelledError, GatewayError):
        pass

    assert queue.stopping is True
    assert queue.depth == 0


async def test_concurrent_stop_calls_enqueue_only_one_sentinel() -> None:
    queue = InferenceQueue(capacity=2, timeout_seconds=1)
    started = threading.Event()
    release = threading.Event()

    def operation() -> int:
        started.set()
        release.wait(0.5)
        return 3

    submit_task = asyncio.create_task(queue.submit("concurrent-stop", "slow", operation))
    assert await asyncio.to_thread(started.wait, 0.1)

    first_stop = asyncio.create_task(queue.stop(timeout_seconds=0.5))
    second_stop = asyncio.create_task(queue.stop(timeout_seconds=0.5))
    await asyncio.sleep(0.01)
    release.set()

    assert await asyncio.wait_for(submit_task, timeout=0.5) == 3
    results = await asyncio.gather(first_stop, second_stop)
    assert results == [True, True]
    assert queue.depth == 0
    assert queue.stopping is True