Skip to content

tests: eliminate NLTK stopwords download race under pytest-xdist#644

Merged
vishal-bala merged 1 commit into
mainfrom
tests/decouple-nltk-stopwords-race
Jul 2, 2026
Merged

tests: eliminate NLTK stopwords download race under pytest-xdist#644
vishal-bala merged 1 commit into
mainfrom
tests/decouple-nltk-stopwords-race

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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


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.

Reviewed by Cursor Bugbot for commit 17f5435. Bugbot is set up for automated code reviews on this repo. Configure here.

Query classes that take a string stopwords value ("english" by default)
lazily download the NLTK stopwords corpus on first use. Under pytest-xdist
several workers can hit the missing corpus at once and call
nltk.download() concurrently, corrupting the shared nltk_data directory and
producing intermittent "stopwords/english not found" failures. This only
surfaced on Python 3.14, where sentence-transformers is skipped and the
corpus is no longer warmed serially before the parallel tests run.

Two complementary changes:

- Add a session-scoped autouse fixture that pre-downloads the corpus once,
  serialized across xdist workers with a file lock, so exactly one process
  performs the download. Adds filelock as a dev dependency.
- Decouple the retrieval-only hybrid tests in test_aggregation.py from NLTK
  by passing stopwords=None. These use optional (~@field) text matching, so
  result counts are driven by the vector/filter and the assertions are
  unaffected. The tests that genuinely exercise stopword behavior
  (test_empty_query_string, test_hybrid_query_stopwords) are left intact and
  covered by the pre-download fixture, as are the count-sensitive TextQuery
  tests in test_query.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishal-bala vishal-bala added the auto:tests Add or improve existing tests label Jul 2, 2026
@jit-ci

jit-ci Bot commented Jul 2, 2026

Copy link
Copy Markdown

🛡️ Jit Security Scan Results

CRITICAL HIGH MEDIUM

✅ No security findings were detected in this PR


Security scan by Jit

@vishal-bala
vishal-bala marked this pull request as ready for review July 2, 2026 12:54

@nkanu17 nkanu17 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@vishal-bala
vishal-bala merged commit 7f33868 into main Jul 2, 2026
57 checks passed
@vishal-bala
vishal-bala deleted the tests/decouple-nltk-stopwords-race branch July 2, 2026 13:25
@rbs333 rbs333 mentioned this pull request Jul 2, 2026
rbs333 added a commit that referenced this pull request Jul 2, 2026
# Drop NLTK dependency; serve stopwords statically

## Why

RedisVL only used NLTK for one thing: default stopword lists in
full-text
query building. Pulling in the whole `nltk` package for that has cost us
repeatedly — packaging/support friction and a recurring source of test
flakiness (parallel pytest-xdist workers racing on `nltk.download()`,
most
recently patched in #644).

The stopword lists are small, static, and change rarely. This PR bundles
them
directly and removes `nltk` entirely.

## What changed

- **New static data** — `redisvl/utils/stopwords.json` (33 languages,
~11k
words, ~120KB), extracted once from NLTK's `stopwords` corpus so the
lists
are byte-identical to what NLTK returned. **No behavior change** for any
  supported language.
- **New loader** — `redisvl/utils/stopwords.py` exposes
`get_stopwords(language)`,
  which lazily reads and caches the JSON via `importlib.resources`.
- **Rewired consumers** — `full_text_query_helper.py` and `query.py` now
call
`get_stopwords()` instead of lazily importing NLTK and downloading the
corpus.
  The download/race/retry blocks are gone.
- **Removed vestigial imports** — `aggregate.py` had unused `nltk`
lazy-imports
  (it already routes through the helper); deleted.
- **Dropped the dependency** — removed the `nltk` extra and its entry in
the
  `all` extra from `pyproject.toml`.
- **Simplified tests** — deleted the `ensure_nltk_stopwords` session
fixture
from `tests/conftest.py`. The xdist download race it guarded against no
  longer exists: there is no download.
- **Spellcheck** — added `stopwords.json` to codespell's `--skip` list
(it's a
  multi-language word-list data file, not prose).

## Backward compatibility

The public API is unchanged. All existing usages still work:

- `stopwords="english"` (default), `"german"`, `"french"`, etc. — any of
the
  33 bundled languages
- `stopwords={"custom", "set"}` / list / tuple
- `stopwords=None` (no filtering)

Error behavior is preserved: an unknown language string raises
`ValueError`
(now with the list of available languages), and a
non-string/non-collection
raises `TypeError`.

The only user-visible change: `pip install redisvl[nltk]` no longer
resolves
(the extra was removed). Worth a changelog note.

## Verification

- All stopword / query / aggregation / hybrid unit tests pass (945
passed);
`german` still yields `der`/`die`/`und`, invalid language →
`ValueError`,
  invalid type → `TypeError`.
- Confirmed `stopwords.json` ships in the built wheel
  (`redisvl/utils/stopwords.json` present in `redisvl-*.whl`).
- pre-commit (format, isort, mypy, codespell) passes.

## Downstream follow-up (separate, not in this PR)

`redis-ai-resources` has two notebooks
(`python-recipes/vector-search/01_redisvl.ipynb`,
`02_hybrid_search.ipynb`) that carry `nltk` in their `%pip install`
lines and
one stale markdown reference. Nothing breaks (they never `import nltk`),
but
the `nltk` install is now dead weight. That cleanup should land
**after** this
release and bump the notebooks' `redisvl` floor to the version that
bundles
stopwords.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches default full-text tokenization for all string `stopwords`
language codes; lists are meant to match NLTK but any drift or packaging
omission of `stopwords.json` would change search behavior. Removing the
`nltk` extra is a breaking install change for users who relied on it.
> 
> **Overview**
> **Removes the `nltk` optional dependency** and ships default full-text
stopword lists as bundled `redisvl/utils/stopwords.json` (~33
languages), loaded via new `get_stopwords()` in
`redisvl/utils/stopwords.py`.
> 
> **Full-text query paths** (`query.py`, `full_text_query_helper.py`)
now call `get_stopwords()` instead of lazy NLTK imports and runtime
`nltk.download()`; unused NLTK imports in `aggregate.py` are removed.
> 
> **Packaging and CI:** `nltk` is dropped from `pyproject.toml`
extras/`all` and `uv.lock`; codespell skips `stopwords.json`. The pytest
session fixture that pre-downloaded NLTK stopwords for xdist is deleted
from `tests/conftest.py`.
> 
> Public stopword API behavior for supported languages and custom sets
is intended to stay the same; `pip install redisvl[nltk]` no longer
applies.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
23a8c33. 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: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@applied-ai-release-bot

Copy link
Copy Markdown

🚀 PR was released in v0.23.0 🚀

@applied-ai-release-bot applied-ai-release-bot Bot added the released This issue/pull request has been released. label Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:tests Add or improve existing tests released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants