Skip to content

Commit f084eb3

Browse files
authored
fix(sbom): release lock before sleeping in _rate_limit (#1896)
* fix(sbom): release lock before sleeping in _rate_limit time.sleep was called inside the _rate_lock block, blocking all threads from checking their own domain rate limit while one thread slept. With _MAX_WORKERS=12 querying crates.io, npm, and pypi concurrently, this made the thread pool effectively serial. Move the sleep outside the lock so threads for different domains can proceed concurrently. * fix(sbom): reserve next slot in _rate_limit to prevent same-domain races Use time.monotonic() and store next_req = max(now, last + interval) so concurrent same-domain callers each get a unique future slot rather than all sleeping the same amount. Add regression tests for same-domain spacing and different-domain non-blocking. * fix(sbom): wire rate-limit tests into test suite via test:sbom task Rename test_resolve_licenses.py to resolve_licenses_test.py to match repo *_test.py convention. Add test:sbom task to tasks/test.toml and include it in the top-level test depends so mise run test picks it up. * fix(sbom): add SPDX header, fix cross-domain regression test to exercise lock contention * fix(sbom): replace zip with itertools.pairwise to satisfy ruff lint
1 parent b689c82 commit f084eb3

3 files changed

Lines changed: 80 additions & 7 deletions

File tree

deploy/sbom/resolve_licenses.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,13 @@
208208

209209
def _rate_limit(domain: str, interval: float = 0.15) -> None:
210210
with _rate_lock:
211-
now = time.time()
212-
last = _last_request.get(domain, 0)
213-
wait = interval - (now - last)
214-
if wait > 0:
215-
time.sleep(wait)
216-
_last_request[domain] = time.time()
211+
now = time.monotonic()
212+
last = _last_request.get(domain, 0.0)
213+
next_req = max(now, last + interval)
214+
_last_request[domain] = next_req
215+
wait = next_req - now
216+
if wait > 0:
217+
time.sleep(wait)
217218

218219

219220
def _get_json(url: str, domain: str) -> dict | None:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import itertools
7+
import threading
8+
import time
9+
from concurrent.futures import ThreadPoolExecutor
10+
11+
from resolve_licenses import _last_request, _rate_limit, _rate_lock
12+
13+
14+
def test_same_domain_requests_are_spaced() -> None:
15+
domain = "test.same-domain.example"
16+
with _rate_lock:
17+
_last_request.pop(domain, None)
18+
19+
interval = 0.05
20+
times: list[float] = []
21+
22+
def call() -> None:
23+
_rate_limit(domain, interval=interval)
24+
times.append(time.monotonic())
25+
26+
with ThreadPoolExecutor(max_workers=3) as pool:
27+
list(pool.map(lambda _: call(), range(3)))
28+
29+
times.sort()
30+
for a, b in itertools.pairwise(times):
31+
assert b - a >= interval * 0.9, f"gap {b - a:.4f}s < interval {interval}s"
32+
33+
34+
def test_different_domains_do_not_block_each_other() -> None:
35+
alpha = "alpha2.example"
36+
beta = "beta2.example"
37+
interval = 0.1
38+
39+
now = time.monotonic()
40+
with _rate_lock:
41+
_last_request[alpha] = now # alpha must sleep for ~interval
42+
_last_request.pop(beta, None) # beta is free
43+
44+
ready = threading.Event()
45+
46+
def call_alpha() -> None:
47+
ready.set()
48+
_rate_limit(alpha, interval=interval)
49+
50+
t = threading.Thread(target=call_alpha)
51+
t.start()
52+
ready.wait()
53+
time.sleep(0.01) # let alpha enter its sleep
54+
55+
beta_start = time.monotonic()
56+
_rate_limit(beta, interval=interval)
57+
beta_elapsed = time.monotonic() - beta_start
58+
59+
t.join()
60+
61+
assert beta_elapsed < interval * 0.5, f"beta blocked for {beta_elapsed:.3f}s"
62+
63+
64+
if __name__ == "__main__":
65+
test_same_domain_requests_are_spaced()
66+
print("same-domain spacing: ok")
67+
test_different_domains_do_not_block_each_other()
68+
print("different-domain non-blocking: ok")

tasks/test.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55

66
[test]
77
description = "Run all tests (Rust + Python)"
8-
depends = ["test:rust", "test:python", "test:install-sh", "test:packaging-assets", "test:docs-website"]
8+
depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "test:packaging-assets", "test:docs-website"]
99

1010
["test:docs-website"]
1111
description = "Test the docs-website sync script"
1212
# --no-project skips the workspace (maturin) build; --with supplies pytest and
1313
# the script's runtime dep (PyYAML), which live outside the project env.
1414
run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sync_docs_website_test.py"
15+
16+
["test:sbom"]
17+
description = "Run SBOM tooling tests"
18+
run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" deploy/sbom/"
1519
hide = true
1620

1721
["test:install-sh"]

0 commit comments

Comments
 (0)