Skip to content

test: Add free-threading test suite and fix thread-safety issues - #3807

Open
FBruzzesi wants to merge 18 commits into
mainfrom
chore/ft-analysis
Open

test: Add free-threading test suite and fix thread-safety issues#3807
FBruzzesi wants to merge 18 commits into
mainfrom
chore/ft-analysis

Conversation

@FBruzzesi

@FBruzzesi FBruzzesi commented Jul 20, 2026

Copy link
Copy Markdown
Member

Description

This PR adds test cases for free-threading support and fixes multiple issues, both in the test suite and in the codebase, surfaced by running the suite concurrently under pytest-run-parallel.

  1. 4c4578e sets up the free-threading tests:

    • adds tests/free_threading_test.py, a dedicated stress-test module that runs narwhals from many threads (cold-cache dispatch, plugin discovery, shared-Expr.over(...) push-down, Enum deferred categories, shared LazyFrame schema, and concurrent narwhals.sql.table);
    • adds a second python-314t CI step that reruns the suite with --parallel-threads=4, a runtime assertion that the GIL is actually off, and a job timeout as a deadlock backstop;
    • registers the thread_unsafe(reason) marker so tests that cannot run concurrently opt out;
    • fixes narwhals.sql: the module-global DuckDB connection and time-zone object are replaced by a per-call cursor (CONN.cursor()), DuckDB's supported per-thread pattern.
  2. 273bf7d fixes the issues with reused pytest.raises contexts: a single pytest.raises(...) object shared across parametrized cases is mutated concurrently under threads.

  3. The remaining two commits target DuckDB issues:

    • 6986332: improves DuckDB thread safety by using one connection per thread. Adds a connection keyword to scan_csv/scan_parquet so DuckDB reads can go through a caller-provided DuckDBPyConnection instead of the process-global default, and uses one connection per thread in the test conftest (relations from different connections cannot be combined, so frames built within one test must share a connection).
    • e83c830 : replaces duckdb.sql(statement), which runs on the process-global default connection, with rel.query(view, sql), which executes on the relation's own connection, for the asof-join, unpivot, and concat operations. top_k is also rewritten from a QUALIFY SQL string to the relational API. Note the trade-off: rel.query creates a view, so these operations no longer work on read-only DuckDB databases (bug: fetch_rel_time_zone fails with read-only MotherDuck connection #3567), unavoidable until the relational API gains ASOF-join / unpivot support upstream.

I left out warnings.catch_warnings(), which is also not thread safe, with the risk of filtering warnings.

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

@FBruzzesi
FBruzzesi marked this pull request as ready for review July 20, 2026 12:59
@FBruzzesi

FBruzzesi commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Apologies in advance for the direct pings


@ngoldbaum any chance you can skim through this work to assess the usage of pytest-run-parallel and the tests in tests/free_threading_test.py?
@spark-dataduck could you take a look at the DuckDB changes? I am replacing duckdb.sql(statement) in favor of rel.query(view, sql) in a few places to make duckdb thread safe as suggested in DuckDB Multiple Python Threads, however as you previous brought up rel.query creates a view, so these operations no longer work on read-only DuckDB databases.


The PR description hopefully has all the details you need to understand the context 🙏🏼

@FBruzzesi FBruzzesi changed the title test: Add free-threading test suite and fix thread-safety issues it surfaced test: Add free-threading test suite and fix thread-safety issues Jul 20, 2026
@ngoldbaum

ngoldbaum commented Jul 20, 2026

Copy link
Copy Markdown

@ngoldbaum any chance you can skim through this work to assess the usage of pytest-run-parallel and the tests in tests/free_threading_test.py?

Sure, although it may be a week or two.

I left out warnings.catch_warnings(), which is also not thread safe, with the risk of filtering warnings.

If you’re using free-threaded 3.14 or newer, warnings are thread-safe by default. If for some reason you want to run pytest-run-parallel on the GIL-enabled build (why?), you can also enable thread_inherit_context and context_aware_warnings for the pytest jobs that generate warnings in parallel. Those are enabled by default on the free-threaded build.

@ngoldbaum

Copy link
Copy Markdown

This PR doesn't touch the docs at all. Should it? The thread-local connection behavior might be surprising.

Also I'm not sure if you're planning to do more free-threaded support work, but I also usually suggest adding explicit docs somewhere on thread safety of the library. Focus on mutable global state and mutable objects.

Is the mutable global state in the library thread-safe? Mutable global state is things like configuration state, caches initialized at runtime, and global mutable singletons.

Are instances of mutable types defined by the library safe to share between threads without external synchronization (e.g. a lock) or coordination (e.g. an algorithm that is safe by construction, like filling disjoint parts of an array with a thread pool)? Note that, for example, mutating NumPy arrays is not thread-safe, so Pandas backed by NumPy is likely susceptible to races for any workflows that tries to write to a data frame. Pandas' arrow backend uses CoW semantics so it doesn't have that problem.

My AI model also found a decent number of additional issues and wrote some scripts to trigger them:

repro_groupby_agg_race.py — run on 3.14t with pandas; expect AssertionError: [10.0, 30.0]. It passes on the GIL-enabled build.

from concurrent.futures import ThreadPoolExecutor

import pandas as pd

import narwhals as nw

df = nw.from_native(pd.DataFrame({"g": [1, 1, 2, 2], "i": [2, 1, 2, 1], "v": [10.0, 20.0, 30.0, 40.0]}), eager_only=True)
gb = df.group_by("g")

def worker(i):
    for _ in range(300):
        expr, expected = (nw.col("v").sum(), [30.0, 70.0]) if i % 2 else (nw.col("v").first(order_by="i"), [20.0, 40.0])
        assert (got := gb.agg(expr).sort("g")["v"].to_list()) == expected, got

with ThreadPoolExecutor(8) as tpe:
    [f.result() for f in [tpe.submit(worker, i) for i in range(8)]]

repro_shared_connection_schema.py — GIL 3.14 with duckdb; expect InvalidInputException: ... unsuccessful or closed pending query result

from concurrent.futures import ThreadPoolExecutor

import duckdb

import narwhals as nw

con = duckdb.connect()
frames = [nw.from_native(con.sql("select timestamptz '2024-01-01' as t")) for _ in range(8)]

def worker(lf):
    for _ in range(50):
        lf.collect_schema()

with ThreadPoolExecutor(8) as tpe:
    [f.result() for f in [tpe.submit(worker, lf) for lf in frames]]

Comment thread .github/workflows/pytest.yml
`rel.query(view, sql)` registers `rel` as a view named `view` and runs `sql` on
`rel`'s own connection (see [relational api]). We prefer it to `duckdb.sql(statement)`,
which uses the global default connection and cannot see relations from other
connections (see [using connections in parallel pythonprograms]).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe it makes sense to have a helper to go along with this one that accepts two queries and verifies that they're from the same connection. I bet users will start seeing errors coming from queries on two different connections because of this.

Also are there memory consequences for this change? The paragraph below indicates that these temporary views are never dropped. If this cost is per-thread, that might add up a lot.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right that the views are never dropped: I couldn't find a way we can just drop the view once the relation is built.

Regarding the same-connection helper, similarly I couldn't find something exposed by a DuckDBPyRelation that can be used for such task. What we can do is raise a better exception by wrapping the exception into our own catch_duckdb_exception and explain the issue in full detail.

In both cases, I will run the questions by an agent and/or ask in a duckdb forum/github discussion to see if there is something I couldn't find from dir(<various duckdb objects>) and their docs 😅

Comment thread src/narwhals/_duckdb/utils.py Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should this be using temporary_view_name() instead of "duckdb_settings()"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I assume that the fixed name is what we want, a unique name per call would instead add one permanent catalog entry per collect_schema() on a time-zone-aware frame. I added a with _TIME_ZONE_LOCK so that the relation is consumed by .fetchone() inside the lock.

I would not expect that to be the expensive operation in a query.


On a second thought, I think renaming it would at least be less confusing (e.g. __narwhals_time_zone__), since the current name reads like it's referring to the table function.

Comment thread src/narwhals/sql.py Outdated
Comment thread tests/free_threading_test.py
@ngoldbaum

Copy link
Copy Markdown

repro_groupby_agg_race.py — run on 3.14t with pandas; expect AssertionError: [10.0, 30.0]. It passes on the GIL-enabled build.

This one triggers on the GIL-enabled build as well if you add sys.setwitchinterval(1e-7) to the top of the repro script, so it's not free-threaded specific per-se, it's just very unlikely with the default swtich interval.

The issue is how this cache gets filled if the AggExpr is shared:

self._grouped = grouped

I think there can be a race to set that attribute. I'm not totally sure why the attribute is being stashed there instead of being passed along.

@FBruzzesi

Copy link
Copy Markdown
Member Author

Thanks for all your inputs @ngoldbaum - those are really really useful insights 🙏🏼
I will try to address all your findings and add a dedicated documentation page for what we can promise from the narwhals layer. Some questions regarding DuckDB, I don't have an answer just yet, I will do some investigation, but thank you for raising the questions in the first place

@FBruzzesi
FBruzzesi marked this pull request as draft August 5, 2026 16:05
@FBruzzesi
FBruzzesi marked this pull request as ready for review August 5, 2026 16:34

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I only did a brief scan but the new docs look nice and this look like it's heading in the right direction!

assert not is_gil_enabled(), (
"The GIL was re-enabled, likely by importing an extension module "
"without free-threading support."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FWIW pytest-run-parallel includes this check out-of-the-box

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants