Skip to content

Commit 09ebd73

Browse files
authored
fix: take lock in PriorityJobQueue.is_done (#100)
* initial implementation of #100 * fix: TOCTOU race * fix: addressed reviewer's comments * fix: code clean-up and fix review comments
1 parent a9aa45a commit 09ebd73

4 files changed

Lines changed: 169 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jobs:
6464
test:
6565
name: Test (Python ${{ matrix.python-version }})
6666
runs-on: ubuntu-latest
67+
timeout-minutes: 15
6768
strategy:
6869
fail-fast: false
6970
matrix:

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- **Default patch pipeline** — near-empty `.localci.yml` uses `patches.profile: generic`. Only `container_mounts`, `image_substitution`, and `codecov_skip` are enabled by default; Capy/B2 steps (`b2_source_cache`, `restore_capy_timestamps`, `capy_copy_preservation`, `b2_bootstrap_skip`) are off unless `profile: capy` or explicitly enabled.
2222
- **`project` defaults**`repo_full_name` and `native_image_prefix` no longer default to `cppalliance/capy` and `capy-` on the generic path (empty by default). `profile: capy` restores the previous Boost.Capy values unless overridden.
2323
- **x86 container architecture**`linux/386` is requested only when no native image prefix is configured or the image tag does not start with `project.native_image_prefix` (empty prefix no longer suppresses 386 for all images).
24+
- `PriorityJobQueue.is_done` takes `self._lock` while reading job and completion counts, same as `is_empty`.
2425

2526
### Fixed
2627

27-
- **`derive_image_tag()` prefix** — honours `project.native_image_prefix` instead of hardcoding `capy-`, completing the generic-profile default behaviour documented for Week 30.
28+
- **`derive_image_tag()` prefix** honours `project.native_image_prefix` instead of hardcoding `capy-`, completing the generic-profile default behaviour documented for Week 30.
29+
- `PriorityJobQueue`: jobs in `WAITING_DEPS` return to `QUEUED` when their dependencies finish. Same-priority `needs` chains were leaving dependents stuck.
30+
- `PriorityJobQueue.cancel_all` drops cancelled keys from `_running_keys`. A `READY` job from `next_ready()` no longer counts as running after cancel.
2831

2932
## [0.1.0] - TBD
3033

cli/localci/core/queue.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,15 @@ def mark_completed(self, job: QueuedJob, success: bool = True) -> None:
249249
self._failed_keys.add(key)
250250
self._completed_keys.add(key)
251251
self._running_keys.discard(key)
252-
self._check_priority_advance()
252+
self._after_job_terminal_state_change()
253253

254254
def mark_skipped(self, job: QueuedJob) -> None:
255255
with self._lock:
256256
key = job.queue_key
257257
job.status = QueuedJobStatus.SKIPPED
258258
self._completed_keys.add(key)
259259
self._running_keys.discard(key)
260-
self._check_priority_advance()
260+
self._after_job_terminal_state_change()
261261

262262
def mark_running(self, job: QueuedJob) -> None:
263263
with self._lock:
@@ -283,7 +283,7 @@ def cancel(self, key: str) -> bool:
283283
job.status = QueuedJobStatus.CANCELLED
284284
self._completed_keys.add(key)
285285
self._emit(JobEventType.JOB_CANCELLED, job)
286-
self._check_priority_advance()
286+
self._after_job_terminal_state_change()
287287
return True
288288

289289
def cancel_all(self) -> int:
@@ -298,11 +298,26 @@ def cancel_all(self) -> int:
298298
):
299299
job.status = QueuedJobStatus.CANCELLED
300300
self._completed_keys.add(key)
301+
self._running_keys.discard(key)
301302
count += 1
302303
if count > 0:
303-
self._check_priority_advance()
304+
self._after_job_terminal_state_change()
304305
return count
305306

307+
def _after_job_terminal_state_change(self) -> None:
308+
self._promote_waiting_deps()
309+
self._check_priority_advance()
310+
311+
def _promote_waiting_deps(self) -> None:
312+
if self._current_priority is None:
313+
return
314+
for key in self._by_priority.get(self._current_priority, []):
315+
job = self._jobs[key]
316+
if job.status != QueuedJobStatus.WAITING_DEPS:
317+
continue
318+
if self._dep_resolver.all_dependencies_met(key, self._completed_keys):
319+
job.status = QueuedJobStatus.QUEUED
320+
306321
def _check_priority_advance(self) -> None:
307322
if self._current_priority is None:
308323
return
@@ -351,7 +366,8 @@ def is_empty(self) -> bool:
351366

352367
@property
353368
def is_done(self) -> bool:
354-
return len(self._jobs) > 0 and len(self._completed_keys) >= len(self._jobs)
369+
with self._lock:
370+
return len(self._jobs) > 0 and len(self._completed_keys) >= len(self._jobs)
355371

356372
@property
357373
def total_jobs(self) -> int:

cli/tests/test_queue.py

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from localci.core.models import (
1111
JobEventType,
1212
QueuedJob,
13+
QueuedJobStatus,
1314
)
1415
from localci.core.queue import (
1516
CyclicDependencyError,
@@ -33,6 +34,19 @@
3334

3435
FIXTURES_DIR = Path(__file__).parent / "fixtures"
3536
FULL_WORKFLOW = FIXTURES_DIR / "sample_workflow.yml"
37+
_THREAD_JOIN_TIMEOUT = 30.0
38+
_CONCURRENT_JOB_COUNT = 100
39+
_CONCURRENT_BATCH_SIZE = _CONCURRENT_JOB_COUNT // 2
40+
41+
42+
def _join_threads(threads: list[threading.Thread], label: str) -> None:
43+
for thread in threads:
44+
thread.join(timeout=_THREAD_JOIN_TIMEOUT)
45+
assert not thread.is_alive(), f"{label} thread did not finish"
46+
47+
48+
def _consumer_deadline() -> float:
49+
return time.monotonic() + _THREAD_JOIN_TIMEOUT
3650

3751

3852
# ---------------------------------------------------------------------------
@@ -186,6 +200,70 @@ def test_same_priority_parallel(self):
186200
second = queue.next_ready()
187201
assert second is not None
188202

203+
def test_waiting_deps_promoted_when_dependency_completes(self):
204+
queue = PriorityJobQueue()
205+
upstream = make_job("Upstream", priority=1, index=0)
206+
downstream = make_job(
207+
"Downstream", priority=1, index=1, deps=[upstream.queue_key]
208+
)
209+
queue.enqueue(upstream)
210+
queue.enqueue(downstream)
211+
212+
first = queue.next_ready()
213+
assert first is not None
214+
assert first.matrix_entry.name == "Upstream"
215+
queue.mark_running(first)
216+
217+
assert queue.next_ready() is None
218+
jobs = {j.queue_key: j for j in queue.get_all_jobs()}
219+
assert jobs[downstream.queue_key].status == QueuedJobStatus.WAITING_DEPS
220+
221+
queue.mark_completed(first, success=True)
222+
223+
second = queue.next_ready()
224+
assert second is not None
225+
assert second.matrix_entry.name == "Downstream"
226+
queue.mark_running(second)
227+
queue.mark_completed(second, success=True)
228+
assert queue.is_done is True
229+
230+
def test_is_done_acquires_lock(self):
231+
queue = PriorityJobQueue()
232+
queue.enqueue(make_job("Job", priority=1))
233+
234+
lock_held = threading.Event()
235+
release_lock = threading.Event()
236+
about_to_read = threading.Event()
237+
238+
def hold_lock() -> None:
239+
with queue._lock:
240+
lock_held.set()
241+
release_lock.wait(timeout=5)
242+
243+
holder = threading.Thread(target=hold_lock)
244+
holder.start()
245+
assert lock_held.wait(timeout=5)
246+
247+
checker_done = threading.Event()
248+
is_done_result: list[bool] = []
249+
250+
def read_is_done() -> None:
251+
about_to_read.set()
252+
is_done_result.append(queue.is_done)
253+
checker_done.set()
254+
255+
checker = threading.Thread(target=read_is_done)
256+
checker.start()
257+
assert about_to_read.wait(timeout=5), "checker never reached is_done read"
258+
assert not checker_done.is_set(), "is_done must block on self._lock"
259+
260+
release_lock.set()
261+
checker.join(timeout=5)
262+
assert not checker.is_alive()
263+
holder.join(timeout=5)
264+
assert not holder.is_alive()
265+
assert is_done_result == [False]
266+
189267
def test_completion_tracking(self):
190268
queue = PriorityJobQueue()
191269
job = make_job("Test", priority=1)
@@ -226,6 +304,18 @@ def test_cancel_all(self):
226304
assert count == 5
227305
assert queue.is_done is True
228306

307+
def test_cancel_all_after_next_ready_clears_running_keys(self):
308+
queue = PriorityJobQueue()
309+
for i in range(3):
310+
queue.enqueue(make_job(f"Job {i}", priority=1, index=i))
311+
ready = queue.next_ready()
312+
assert ready is not None
313+
count = queue.cancel_all()
314+
assert count == 3
315+
assert queue.running_count == 0
316+
assert queue.pending_count >= 0
317+
assert queue.is_done is True
318+
229319
def test_event_emission(self):
230320
events = []
231321
queue = PriorityJobQueue()
@@ -332,23 +422,70 @@ def enqueue_batch(start: int, count: int) -> None:
332422
queue.enqueue(make_job(f"Job {i}", priority=1, index=i))
333423

334424
threads = [
335-
threading.Thread(target=enqueue_batch, args=(0, 50)),
336-
threading.Thread(target=enqueue_batch, args=(50, 50)),
425+
threading.Thread(target=enqueue_batch, args=(0, _CONCURRENT_BATCH_SIZE)),
426+
threading.Thread(
427+
target=enqueue_batch,
428+
args=(_CONCURRENT_BATCH_SIZE, _CONCURRENT_BATCH_SIZE),
429+
),
337430
]
338431
for t in threads:
339432
t.start()
340-
for t in threads:
341-
t.join()
342-
assert queue.total_jobs == 100
433+
_join_threads(threads, "producer")
434+
assert queue.total_jobs == _CONCURRENT_JOB_COUNT
435+
436+
def test_concurrent_producers_and_consumers(self):
437+
queue = PriorityJobQueue()
438+
producers_done = threading.Event()
439+
consumer_deadline = _consumer_deadline()
440+
441+
def enqueue_batch(start: int, count: int) -> None:
442+
for i in range(start, start + count):
443+
queue.enqueue(make_job(f"Job {i}", priority=1, index=i))
444+
time.sleep(0.001)
445+
446+
def consume() -> None:
447+
while not (producers_done.is_set() and queue.is_done):
448+
if time.monotonic() >= consumer_deadline:
449+
return
450+
job = queue.next_ready()
451+
if job is None:
452+
time.sleep(0.001)
453+
continue
454+
queue.mark_running(job)
455+
queue.mark_completed(job, success=True)
456+
457+
producers = [
458+
threading.Thread(target=enqueue_batch, args=(0, _CONCURRENT_BATCH_SIZE)),
459+
threading.Thread(
460+
target=enqueue_batch,
461+
args=(_CONCURRENT_BATCH_SIZE, _CONCURRENT_BATCH_SIZE),
462+
),
463+
]
464+
consumers = [threading.Thread(target=consume) for _ in range(4)]
465+
466+
for t in producers:
467+
t.start()
468+
for t in consumers:
469+
t.start()
470+
471+
_join_threads(producers, "producer")
472+
producers_done.set()
473+
_join_threads(consumers, "consumer")
474+
475+
assert queue.total_jobs == _CONCURRENT_JOB_COUNT
476+
assert queue.passed_count == _CONCURRENT_JOB_COUNT
343477

344478
def test_concurrent_dequeue(self):
345479
queue = PriorityJobQueue()
346480
for i in range(20):
347481
queue.enqueue(make_job(f"Job {i}", priority=1, index=i))
348482
results: list[QueuedJob] = []
483+
consumer_deadline = _consumer_deadline()
349484

350485
def consume() -> None:
351486
while not queue.is_done:
487+
if time.monotonic() >= consumer_deadline:
488+
return
352489
job = queue.next_ready()
353490
if job is None:
354491
time.sleep(0.01)
@@ -360,8 +497,7 @@ def consume() -> None:
360497
threads = [threading.Thread(target=consume) for _ in range(4)]
361498
for t in threads:
362499
t.start()
363-
for t in threads:
364-
t.join()
500+
_join_threads(threads, "consumer")
365501
assert len(results) == 20
366502
names = {r.matrix_entry.name for r in results}
367503
assert len(names) == 20

0 commit comments

Comments
 (0)