diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index 542c944c8..069527b21 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -6,6 +6,7 @@ import os import sys import threading import warnings +from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime, timedelta from time import sleep from typing import List @@ -2124,6 +2125,27 @@ def test_delete(mem_db: DBConnection): assert table.to_arrow()["id"].to_pylist() == [1] +def test_concurrent_deletes_are_thread_safe(mem_db: DBConnection): + num_workers = 8 + table = mem_db.create_table( + "my_table", data=[{"id": row_id} for row_id in range(num_workers)] + ) + barrier = threading.Barrier(num_workers) + + def delete(row_id: int): + barrier.wait() + return table.delete(f"id = {row_id}") + + with ThreadPoolExecutor(max_workers=num_workers) as pool: + results = list(pool.map(delete, range(num_workers))) + + assert all(result.num_deleted_rows == 1 for result in results) + assert sorted(result.version for result in results) == list( + range(2, num_workers + 2) + ) + assert table.count_rows() == 0 + + def test_delete_expr(mem_db: DBConnection): table = mem_db.create_table( "my_table", diff --git a/python/src/table.rs b/python/src/table.rs index f20bcf2bf..5b5d6596a 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -745,6 +745,9 @@ impl Table { #[allow(private_interfaces)] pub fn delete(self_: PyRef<'_, Self>, condition: PredicateArg) -> PyResult> { + // Do not hold the Python borrow across the await. The cloned Rust table + // handle is thread-safe and allows deletes on the same Python table to + // run concurrently without PyO3 reporting "Already borrowed". let inner = self_.inner_ref()?.clone(); future_into_py(self_.py(), async move { let result = match &condition {