Skip to content

Commit d95e343

Browse files
authored
chore: Add POLARS_TIMEOUT_MS for timing out slow Polars tests (#21887)
1 parent 3c2ac5b commit d95e343

File tree

7 files changed

+122
-3
lines changed

7 files changed

+122
-3
lines changed

.github/workflows/benchmark.yml

+3
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,12 @@ jobs:
139139
- name: Run non-benchmark tests
140140
working-directory: py-polars
141141
run: pytest -m 'not benchmark and not debug' -n auto
142+
env:
143+
POLARS_TIMEOUT_MS: 60000
142144

143145
- name: Run non-benchmark tests on new streaming engine
144146
working-directory: py-polars
145147
env:
146148
POLARS_AUTO_NEW_STREAMING: 1
149+
POLARS_TIMEOUT_MS: 60000
147150
run: pytest -n auto -m "not may_fail_auto_streaming and not slow and not write_disk and not release and not docs and not hypothesis and not benchmark and not ci_only"

.github/workflows/test-coverage.yml

+3
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ jobs:
133133

134134
- name: Run Python tests
135135
working-directory: py-polars
136+
env:
137+
POLARS_TIMEOUT_MS: 60000
136138
run: >
137139
pytest
138140
-n auto
@@ -144,6 +146,7 @@ jobs:
144146
working-directory: py-polars
145147
env:
146148
POLARS_FORCE_ASYNC: 1
149+
POLARS_TIMEOUT_MS: 60000
147150
run: >
148151
pytest tests/unit/io/
149152
-n auto

.github/workflows/test-python.yml

+4
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,22 @@ jobs:
9191
9292
- name: Run tests
9393
if: github.ref_name != 'main'
94+
env:
95+
POLARS_TIMEOUT_MS: 60000
9496
run: pytest -n auto -m "not release and not benchmark and not docs"
9597

9698
- name: Run tests with new streaming engine
9799
if: github.ref_name != 'main'
98100
env:
99101
POLARS_AUTO_NEW_STREAMING: 1
102+
POLARS_TIMEOUT_MS: 60000
100103
run: pytest -n auto -m "not may_fail_auto_streaming and not slow and not write_disk and not release and not docs and not hypothesis and not benchmark and not ci_only"
101104

102105
- name: Run tests async reader tests
103106
if: github.ref_name != 'main' && matrix.os != 'windows-latest'
104107
env:
105108
POLARS_FORCE_ASYNC: 1
109+
POLARS_TIMEOUT_MS: 60000
106110
run: pytest -n auto -m "not release and not benchmark and not docs" tests/unit/io/
107111

108112
- name: Check import without optional dependencies

crates/polars-python/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mod py_modules;
3636
pub mod series;
3737
#[cfg(feature = "sql")]
3838
pub mod sql;
39+
pub mod timeout;
3940
pub mod utils;
4041

4142
use crate::conversion::Wrap;

crates/polars-python/src/timeout.rs

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! A global process-aborting timeout system, mainly intended for testing.
2+
3+
use std::cmp::Reverse;
4+
use std::collections::BinaryHeap;
5+
use std::sync::LazyLock;
6+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7+
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
8+
use std::time::Duration;
9+
10+
use polars::prelude::{InitHashMaps, PlHashSet};
11+
use polars_utils::priority::Priority;
12+
13+
static TIMEOUT_REQUEST_HANDLER: LazyLock<Sender<TimeoutRequest>> = LazyLock::new(|| {
14+
let (send, recv) = channel();
15+
std::thread::Builder::new()
16+
.name("polars-timeout".to_string())
17+
.spawn(move || timeout_thread(recv))
18+
.unwrap();
19+
send
20+
});
21+
22+
enum TimeoutRequest {
23+
Start(Duration, u64),
24+
Cancel(u64),
25+
}
26+
27+
pub fn get_timeout() -> Option<Duration> {
28+
static TIMEOUT_DISABLED: AtomicBool = AtomicBool::new(false);
29+
30+
// Fast path so we don't have to keep checking environment variables. Make
31+
// sure that if you want to use POLARS_TIMEOUT_MS it is set before the first
32+
// polars call.
33+
if TIMEOUT_DISABLED.load(Ordering::Relaxed) {
34+
return None;
35+
}
36+
37+
let Ok(timeout) = std::env::var("POLARS_TIMEOUT_MS") else {
38+
TIMEOUT_DISABLED.store(true, Ordering::Relaxed);
39+
return None;
40+
};
41+
42+
match timeout.parse() {
43+
Ok(ms) => Some(Duration::from_millis(ms)),
44+
Err(e) => {
45+
eprintln!("failed to parse POLARS_TIMEOUT_MS: {e:?}");
46+
None
47+
},
48+
}
49+
}
50+
51+
fn timeout_thread(recv: Receiver<TimeoutRequest>) {
52+
let mut active_timeouts: PlHashSet<u64> = PlHashSet::new();
53+
let mut shortest_timeout: BinaryHeap<Priority<Reverse<Duration>, u64>> = BinaryHeap::new();
54+
loop {
55+
// Remove cancelled requests.
56+
while let Some(Priority(_, id)) = shortest_timeout.peek() {
57+
if active_timeouts.contains(id) {
58+
break;
59+
}
60+
shortest_timeout.pop();
61+
}
62+
63+
let request = if let Some(Priority(timeout, _)) = shortest_timeout.peek() {
64+
match recv.recv_timeout(timeout.0) {
65+
Err(RecvTimeoutError::Timeout) => {
66+
eprintln!("exiting the process, POLARS_TIMEOUT_MS exceeded");
67+
std::thread::sleep(Duration::from_secs_f64(1.0));
68+
std::process::exit(1);
69+
},
70+
r => r.unwrap(),
71+
}
72+
} else {
73+
recv.recv().unwrap()
74+
};
75+
76+
match request {
77+
TimeoutRequest::Start(duration, id) => {
78+
shortest_timeout.push(Priority(Reverse(duration), id));
79+
active_timeouts.insert(id);
80+
},
81+
TimeoutRequest::Cancel(id) => {
82+
active_timeouts.remove(&id);
83+
},
84+
}
85+
}
86+
}
87+
88+
pub fn schedule_polars_timeout() -> Option<u64> {
89+
static TIMEOUT_ID: AtomicU64 = AtomicU64::new(0);
90+
91+
let timeout = get_timeout()?;
92+
let id = TIMEOUT_ID.fetch_add(1, Ordering::Relaxed);
93+
TIMEOUT_REQUEST_HANDLER
94+
.send(TimeoutRequest::Start(timeout, id))
95+
.unwrap();
96+
Some(id)
97+
}
98+
99+
pub fn cancel_polars_timeout(opt_id: Option<u64>) {
100+
if let Some(id) = opt_id {
101+
TIMEOUT_REQUEST_HANDLER
102+
.send(TimeoutRequest::Cancel(id))
103+
.unwrap();
104+
}
105+
}

crates/polars-python/src/utils.rs

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use pyo3::{PyErr, PyResult, Python};
1111
use crate::dataframe::PyDataFrame;
1212
use crate::error::PyPolarsErr;
1313
use crate::series::PySeries;
14+
use crate::timeout::{cancel_polars_timeout, schedule_polars_timeout};
1415

1516
// was redefined because I could not get feature flags activated?
1617
#[macro_export]
@@ -101,7 +102,9 @@ impl EnterPolarsExt for Python<'_> {
101102
T: Ungil + Send,
102103
E: Ungil + Send + Into<PyPolarsErr>,
103104
{
105+
let timeout = schedule_polars_timeout();
104106
let ret = self.allow_threads(|| catch_keyboard_interrupt(AssertUnwindSafe(f)));
107+
cancel_polars_timeout(timeout);
105108
match ret {
106109
Ok(Ok(ret)) => Ok(ret),
107110
Ok(Err(err)) => Err(PyErr::from(err.into())),

py-polars/Makefile

+3-3
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ pre-commit: fmt clippy ## Run all code formatting and lint/quality checks
6969

7070
.PHONY: test
7171
test: .venv build ## Run fast unittests
72-
$(VENV_BIN)/pytest -n auto $(PYTEST_ARGS)
72+
POLARS_TIMEOUT_MS=60000 $(VENV_BIN)/pytest -n auto $(PYTEST_ARGS)
7373

7474
.PHONY: test-all
7575
test-all: .venv build ## Run all tests
76-
$(VENV_BIN)/pytest -n auto -m "slow or not slow"
76+
POLARS_TIMEOUT_MS=60000 $(VENV_BIN)/pytest -n auto -m "slow or not slow"
7777
$(VENV_BIN)/python tests/docs/run_doctest.py
7878

7979
.PHONY: doctest
@@ -92,7 +92,7 @@ docs-clean: .venv ## Build Python docs (full rebuild)
9292

9393
.PHONY: coverage
9494
coverage: .venv build ## Run tests and report coverage
95-
$(VENV_BIN)/pytest --cov -n auto -m "not release and not benchmark"
95+
POLARS_TIMEOUT_MS=60000 $(VENV_BIN)/pytest --cov -n auto -m "not release and not benchmark"
9696

9797
.PHONY: clean
9898
clean: ## Clean up caches and build artifacts

0 commit comments

Comments
 (0)