Skip to content

Commit 7f33868

Browse files
vishal-balaclaude
andauthored
tests: eliminate NLTK stopwords download race under pytest-xdist (#644)
## Motivation `TextQuery`, `HybridQuery`, and `AggregateHybridQuery` default to `stopwords="english"` and lazily download the NLTK corpus the first time they need it. Under pytest-xdist, several workers can hit the missing corpus at the same moment and call `nltk.download()` concurrently — their unzips clobber each other in the shared `nltk_data` directory, and a worker then reads a half-written corpus. It surfaces only on Python 3.14 because there `sentence-transformers` is skipped, so nothing warms the corpus serially before the parallel tests start; on 3.10–3.13 the HuggingFace path fetches it first and the race never opens. It's intermittent (only some 3.14 jobs, across `redis:8.2` and `redis:latest` alike), which is the giveaway that Redis has nothing to do with it. ## Fix Two complementary changes, following the pattern the unit tests already use (they pass `stopwords=None` to avoid NLTK entirely): **Pre-download the corpus once, before the workers race for it.** A new session-scoped autouse fixture fetches `stopwords` a single time, serialized across xdist workers with a file lock, so exactly one process does the download and the rest find it already present. This covers every test that legitimately needs english stopwords — including the ones that assert on stopword filtering (`test_empty_query_string`, `test_hybrid_query_stopwords`) and the count-sensitive `TextQuery` tests in `test_query.py`, whose result counts depend on filtering and so must keep real stopwords. Adds `filelock` (already present transitively) as an explicit dev dependency. **Decouple the tests that don't actually need stopwords.** The hybrid tests in `test_aggregation.py` match text with an optional `~@field` clause, so the result set is driven by the vector and filters, not the text tokens — dropping stopwords there changes nothing about the assertions. Passing `stopwords=None` on those constructions removes NLTK from that file except the two tests that specifically exercise the feature, shrinking the surface that can ever race. ## Verification Ran the affected tests under `-n 8` with the stopwords corpus deleted beforehand to simulate a cold runner. The fixture downloads once, no worker sees a partial corpus, and all tests pass — including the count-sensitive `TextQuery` tests, confirming english-stopword filtering is still intact. Independent of the `redis:latest` WORKERS fix (#641); this addresses a separate, pre-existing flake. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test-only and dev-dependency changes; no production library behavior is modified. > > **Overview** > Fixes intermittent CI failures when parallel pytest workers concurrently download the NLTK **stopwords** corpus into a shared `nltk_data` directory. > > A new session-scoped autouse fixture **`ensure_nltk_stopwords`** in `conftest.py` ensures the corpus exists once before tests run; under **pytest-xdist**, a **`filelock`** serializes the download so only one worker fetches it. **`filelock`** is added as an explicit dev dependency in **`pyproject.toml`** / **`uv.lock`**. > > Hybrid aggregation integration tests that do not assert on stopword behavior now pass **`stopwords=None`** on **`AggregateHybridQuery`** so those cases skip NLTK entirely; tests that exercise stopword filtering keep default or explicit stopword lists. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 17f5435. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0ecf53e commit 7f33868

4 files changed

Lines changed: 53 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ dev = [
105105
"types-pyyaml",
106106
"types-pyopenssl",
107107
"testcontainers>=4.3.1,<5",
108+
"filelock>=3.12,<4",
108109
"cryptography>=44.0.1",
109110
"codespell>=2.4.1,<3",
110111
"langcache>=0.9.0",

tests/conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,43 @@ def set_tokenizers_parallelism():
5353
os.environ["TOKENIZERS_PARALLELISM"] = "false"
5454

5555

56+
@pytest.fixture(scope="session", autouse=True)
57+
def ensure_nltk_stopwords(tmp_path_factory, worker_id):
58+
"""Pre-download the NLTK ``stopwords`` corpus once, before tests run.
59+
60+
Query classes that take a string ``stopwords`` value ("english" by
61+
default) lazily download the corpus on first use. Under pytest-xdist
62+
several workers can hit the missing corpus simultaneously and call
63+
``nltk.download()`` concurrently, corrupting the shared ``nltk_data``
64+
directory and producing intermittent "stopwords/english not found"
65+
failures. Fetch it here once, serializing across workers with a file lock
66+
so exactly one process performs the download.
67+
"""
68+
try:
69+
import nltk
70+
except ImportError:
71+
return
72+
73+
def _ensure() -> None:
74+
try:
75+
nltk.data.find("corpora/stopwords")
76+
except LookupError:
77+
nltk.download("stopwords", quiet=True)
78+
79+
# Not running under xdist: download in-process.
80+
if worker_id == "master":
81+
_ensure()
82+
return
83+
84+
# Under xdist: the first worker to grab the lock downloads; the others
85+
# wait, then find the corpus already present.
86+
from filelock import FileLock
87+
88+
root_tmp = tmp_path_factory.getbasetemp().parent
89+
with FileLock(str(root_tmp / "nltk_stopwords.lock")):
90+
_ensure()
91+
92+
5693
@pytest.fixture(scope="session", autouse=True)
5794
def redis_container(worker_id):
5895
"""

tests/integration/test_aggregation.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def test_hybrid_query(index):
9696
vector=vector,
9797
vector_field_name=vector_field,
9898
return_fields=return_fields,
99+
stopwords=None,
99100
)
100101

101102
results = index.query(hybrid_query)
@@ -122,6 +123,7 @@ def test_hybrid_query(index):
122123
vector=vector,
123124
vector_field_name=vector_field,
124125
num_results=3,
126+
stopwords=None,
125127
)
126128

127129
results = index.query(hybrid_query)
@@ -177,6 +179,7 @@ def test_hybrid_query_with_filter(index):
177179
vector_field_name=vector_field,
178180
filter_expression=filter_expression,
179181
return_fields=return_fields,
182+
stopwords=None,
180183
)
181184

182185
results = index.query(hybrid_query)
@@ -203,6 +206,7 @@ def test_hybrid_query_with_geo_filter(index):
203206
vector_field_name=vector_field,
204207
filter_expression=filter_expression,
205208
return_fields=return_fields,
209+
stopwords=None,
206210
)
207211

208212
results = index.query(hybrid_query)
@@ -226,6 +230,7 @@ def test_hybrid_query_alpha(index, alpha):
226230
vector=vector,
227231
vector_field_name=vector_field,
228232
alpha=alpha,
233+
stopwords=None,
229234
)
230235

231236
results = index.query(hybrid_query)
@@ -291,6 +296,7 @@ def test_hybrid_query_with_text_filter(index):
291296
alpha=0.5,
292297
filter_expression=filter_expression,
293298
return_fields=["job", "description"],
299+
stopwords=None,
294300
)
295301

296302
results = index.query(hybrid_query)
@@ -309,6 +315,7 @@ def test_hybrid_query_with_text_filter(index):
309315
alpha=0.5,
310316
filter_expression=filter_expression,
311317
return_fields=["description"],
318+
stopwords=None,
312319
)
313320

314321
results = index.query(hybrid_query)
@@ -339,6 +346,7 @@ def test_hybrid_query_word_weights(index, scorer):
339346
return_fields=return_fields,
340347
text_scorer=scorer,
341348
text_weights=weights,
349+
stopwords=None,
342350
)
343351

344352
weighted_results = index.query(weighted_query)
@@ -353,6 +361,7 @@ def test_hybrid_query_word_weights(index, scorer):
353361
return_fields=return_fields,
354362
text_scorer=scorer,
355363
text_weights={},
364+
stopwords=None,
356365
)
357366

358367
unweighted_results = index.query(unweighted_query)
@@ -372,6 +381,7 @@ def test_hybrid_query_word_weights(index, scorer):
372381
return_fields=return_fields,
373382
text_scorer=scorer,
374383
text_weights=weights,
384+
stopwords=None,
375385
)
376386

377387
weighted_results = index.query(weighted_query)
@@ -386,6 +396,7 @@ def test_hybrid_query_word_weights(index, scorer):
386396
return_fields=return_fields,
387397
text_scorer=scorer,
388398
text_weights=None,
399+
stopwords=None,
389400
)
390401

391402
new_query.set_text_weights(weights)

uv.lock

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)