Skip to content

Commit 6848884

Browse files
authored
Merge pull request #625 from NeptuneHub/devel
Windows sigalarm fix - #624
2 parents f9cdc8b + d46e82d commit 6848884

19 files changed

Lines changed: 1400 additions & 3 deletions

taskqueue.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,29 @@
1111
the macOS supervisor launches the bundled ``redis-server`` binary (see
1212
:func:`build_embedded_redis_argv`) and exports its socket URL as ``REDIS_URL``
1313
before the app and workers boot.
14+
15+
``rq`` hardcodes ``UnixSignalDeathPenalty`` as every job registry's default, so
16+
on Windows (no ``signal.SIGALRM``) registry cleanup -- the janitor loop and the
17+
workers' periodic maintenance -- raises ``AttributeError`` whenever an abandoned
18+
job carries an ``on_failure`` callback, and the job is never removed.
19+
``BaseRegistry`` is therefore re-pointed at RQ's own platform dispatcher and
20+
the queues below are built with the same class: a no-op on POSIX, the
21+
timer-based penalty (what RQ's workers already use) on Windows.
1422
"""
1523

1624
from redis import Redis
1725
from rq import Queue, get_current_job
1826
from rq.job import Job
1927
from rq.exceptions import NoSuchJobError
2028
from rq.command import send_stop_job_command
29+
from rq.registry import BaseRegistry
30+
from rq.timeouts import get_default_death_penalty_class
2131

2232
import config
2333

34+
_death_penalty_class = get_default_death_penalty_class()
35+
BaseRegistry.death_penalty_class = _death_penalty_class
36+
2437
__all__ = [
2538
"redis_conn",
2639
"rq_queue_high",
@@ -54,8 +67,8 @@ def redis_socket_options(url):
5467
**redis_socket_options(config.REDIS_URL),
5568
)
5669

57-
rq_queue_high = Queue('high', connection=redis_conn, default_timeout=-1)
58-
rq_queue_default = Queue('default', connection=redis_conn, default_timeout=-1)
70+
rq_queue_high = Queue('high', connection=redis_conn, default_timeout=-1, death_penalty_class=_death_penalty_class)
71+
rq_queue_default = Queue('default', connection=redis_conn, default_timeout=-1, death_penalty_class=_death_penalty_class)
5972

6073

6174
def build_embedded_redis_argv(server_binary, socket_path, data_dir):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from flask import Flask
4+
5+
from app_alchemy import alchemy_bp
6+
7+
8+
@pytest.fixture
9+
def app():
10+
app = Flask(__name__)
11+
app.register_blueprint(alchemy_bp)
12+
app.config['TESTING'] = True
13+
return app
14+
15+
16+
@pytest.fixture
17+
def client(app):
18+
return app.test_client()
19+
20+
21+
class TestCreateAnchorValidation:
22+
@patch('app_helper.save_alchemy_anchor')
23+
def test_whitespace_only_name_returns_400(self, mock_save, client):
24+
response = client.post('/api/anchors', json={'name': ' ', 'centroid': [0.1, 0.2]})
25+
assert response.status_code == 400
26+
assert response.get_json() == {'error': 'Anchor name is required'}
27+
mock_save.assert_not_called()
28+
29+
@patch('app_helper.save_alchemy_anchor')
30+
def test_non_list_centroid_returns_400(self, mock_save, client):
31+
response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': 'not-a-list'})
32+
assert response.status_code == 400
33+
assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'}
34+
mock_save.assert_not_called()
35+
36+
@patch('app_helper.save_alchemy_anchor')
37+
def test_empty_list_centroid_returns_400(self, mock_save, client):
38+
response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': []})
39+
assert response.status_code == 400
40+
assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'}
41+
mock_save.assert_not_called()
42+
43+
44+
class TestRenameAnchorValidation:
45+
@patch('app_helper.update_alchemy_anchor_name')
46+
def test_whitespace_only_name_returns_400(self, mock_update, client):
47+
response = client.put('/api/anchors/7', json={'name': ' '})
48+
assert response.status_code == 400
49+
assert response.get_json() == {'error': 'Anchor name is required'}
50+
mock_update.assert_not_called()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from flask import Flask
4+
5+
from app_alchemy import alchemy_bp
6+
7+
8+
@pytest.fixture
9+
def app():
10+
app = Flask(__name__)
11+
app.register_blueprint(alchemy_bp)
12+
app.config['TESTING'] = True
13+
return app
14+
15+
16+
@pytest.fixture
17+
def client(app):
18+
return app.test_client()
19+
20+
21+
class TestAlchemyApiPayloadValidation:
22+
def test_items_without_any_add_op_returns_400(self, client):
23+
response = client.post('/api/alchemy', json={'items': [{'id': 'song-1', 'op': 'SUBTRACT'}]})
24+
assert response.status_code == 400
25+
assert response.get_json() == {'error': 'Invalid request'}
26+
27+
def test_add_item_missing_id_returns_400(self, client):
28+
response = client.post('/api/alchemy', json={'items': [{'op': 'ADD', 'type': 'song'}]})
29+
assert response.status_code == 400
30+
assert response.get_json() == {'error': 'Invalid request'}
31+
32+
@patch('app_alchemy.song_alchemy')
33+
def test_add_items_without_id_are_filtered_before_dispatch(self, mock_alchemy, client):
34+
mock_alchemy.side_effect = ValueError('At least one item must be in the ADD set')
35+
response = client.post('/api/alchemy', json={'items': [{'op': 'ADD'}, {'op': 'ADD', 'id': ''}]})
36+
assert response.status_code == 400
37+
assert mock_alchemy.call_args.kwargs['add_items'] == []
38+
assert mock_alchemy.call_args.kwargs['subtract_items'] == []
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Unit tests for app_backup.restore_backup() chunk and lock validation.
2+
3+
Exercises the /api/backup/restore route through a Flask test client with
4+
multipart/form-data uploads. The module-level Redis lock helpers are patched
5+
(the functions, not redis), BACKUP_DIR is redirected to a pytest tmp dir, and
6+
no test ever completes a chunk set, so the detached restore subprocess is
7+
never spawned.
8+
"""
9+
import io
10+
import os
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
from flask import Flask
15+
16+
import app_backup
17+
18+
CONFIRMATION = "I want to restore the database from the backup. This action is not reversible"
19+
20+
21+
@pytest.fixture
22+
def client():
23+
app = Flask(__name__)
24+
app.config['TESTING'] = True
25+
app.register_blueprint(app_backup.backup_bp)
26+
return app.test_client()
27+
28+
29+
def _form(confirmation=CONFIRMATION, chunk_num=None, total_chunks=None, with_file=True):
30+
data = {'confirmation': confirmation}
31+
if chunk_num is not None:
32+
data['chunk_num'] = str(chunk_num)
33+
if total_chunks is not None:
34+
data['total_chunks'] = str(total_chunks)
35+
if with_file:
36+
data['file'] = (io.BytesIO(b'SELECT 1;\n'), 'backup.sql')
37+
return data
38+
39+
40+
def _post(client, **kwargs):
41+
return client.post(
42+
'/api/backup/restore',
43+
data=_form(**kwargs),
44+
content_type='multipart/form-data',
45+
)
46+
47+
48+
class TestRestoreValidation:
49+
def test_wrong_confirmation_is_400(self, client):
50+
resp = _post(client, confirmation='nope')
51+
assert resp.status_code == 400
52+
assert 'Confirmation' in resp.get_json()['error']
53+
54+
def test_missing_confirmation_is_400(self, client):
55+
resp = _post(client, confirmation='')
56+
assert resp.status_code == 400
57+
58+
def test_missing_file_is_400(self, client):
59+
resp = _post(client, with_file=False)
60+
assert resp.status_code == 400
61+
assert resp.get_json()['error'] == 'No file uploaded.'
62+
63+
def test_non_integer_chunk_fields_are_400(self, client):
64+
resp = _post(client, chunk_num='abc', total_chunks='3')
65+
assert resp.status_code == 400
66+
assert 'must be integers' in resp.get_json()['error']
67+
68+
@pytest.mark.parametrize('chunk_num,total_chunks', [
69+
(0, 3),
70+
(4, 3),
71+
(2, 1),
72+
(-1, 3),
73+
(0, 0),
74+
])
75+
def test_chunk_num_out_of_range_is_400(self, client, chunk_num, total_chunks):
76+
resp = _post(client, chunk_num=chunk_num, total_chunks=total_chunks)
77+
assert resp.status_code == 400
78+
assert 'Invalid chunk numbers' in resp.get_json()['error']
79+
80+
81+
class TestRestoreLock:
82+
def test_first_chunk_lock_already_held_is_409(self, client, monkeypatch, tmp_path):
83+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
84+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False)
85+
resp = _post(client, chunk_num=1, total_chunks=3)
86+
assert resp.status_code == 409
87+
assert 'already in progress' in resp.get_json()['error']
88+
89+
def test_later_chunk_lock_not_held_is_409(self, client, monkeypatch, tmp_path):
90+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
91+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False)
92+
resp = _post(client, chunk_num=2, total_chunks=3)
93+
assert resp.status_code == 409
94+
assert 'Restart the upload from chunk 1' in resp.get_json()['error']
95+
96+
def test_later_chunk_never_tries_to_acquire(self, client, monkeypatch, tmp_path):
97+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
98+
acquire = MagicMock(return_value=True)
99+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', acquire)
100+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False)
101+
resp = _post(client, chunk_num=2, total_chunks=3)
102+
assert resp.status_code == 409
103+
acquire.assert_not_called()
104+
105+
def test_single_file_upload_lock_held_is_409(self, client, monkeypatch):
106+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False)
107+
resp = _post(client)
108+
assert resp.status_code == 409
109+
assert 'already in progress' in resp.get_json()['error']
110+
111+
112+
class TestRestoreChunkProgress:
113+
def test_intermediate_chunk_is_acknowledged(self, client, monkeypatch, tmp_path):
114+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
115+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True)
116+
resp = _post(client, chunk_num=1, total_chunks=3)
117+
assert resp.status_code == 200
118+
body = resp.get_json()
119+
assert body['success'] is True
120+
assert body['all_chunks_received'] is False
121+
assert body['chunk_num'] == 1
122+
assert body['total_chunks'] == 3
123+
assert body['received_chunks'] == [1]
124+
assert body['missing_chunks'] == [2, 3]
125+
assert os.path.exists(os.path.join(str(tmp_path), 'chunks', 'backup_1_of_3.sql'))
126+
127+
def test_first_chunk_wipes_leftover_chunks(self, client, monkeypatch, tmp_path):
128+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
129+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True)
130+
chunks_dir = tmp_path / 'chunks'
131+
chunks_dir.mkdir()
132+
leftover = chunks_dir / 'backup_2_of_3.sql'
133+
leftover.write_bytes(b'stale data')
134+
resp = _post(client, chunk_num=1, total_chunks=3)
135+
assert resp.status_code == 200
136+
body = resp.get_json()
137+
assert body['received_chunks'] == [1]
138+
assert body['missing_chunks'] == [2, 3]
139+
assert not leftover.exists()
140+
141+
def test_second_chunk_keeps_existing_chunks(self, client, monkeypatch, tmp_path):
142+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
143+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: True)
144+
chunks_dir = tmp_path / 'chunks'
145+
chunks_dir.mkdir()
146+
(chunks_dir / 'backup_1_of_3.sql').write_bytes(b'first chunk')
147+
resp = _post(client, chunk_num=2, total_chunks=3)
148+
assert resp.status_code == 200
149+
body = resp.get_json()
150+
assert body['all_chunks_received'] is False
151+
assert body['received_chunks'] == [1, 2]
152+
assert body['missing_chunks'] == [3]
153+
assert (chunks_dir / 'backup_1_of_3.sql').exists()
154+
assert (chunks_dir / 'backup_2_of_3.sql').exists()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import os
2+
import time
3+
4+
import pytest
5+
6+
from app_cron import _field_matches, cron_matches_now
7+
8+
9+
FIXED_TS = 1700000000
10+
11+
requires_tzset = pytest.mark.skipif(
12+
not hasattr(time, 'tzset'), reason='time.tzset not available on this platform'
13+
)
14+
15+
16+
@pytest.fixture
17+
def utc_tz():
18+
old = os.environ.get('TZ')
19+
os.environ['TZ'] = 'UTC'
20+
time.tzset()
21+
yield
22+
if old is None:
23+
os.environ.pop('TZ', None)
24+
else:
25+
os.environ['TZ'] = old
26+
time.tzset()
27+
28+
29+
def test_field_matches_star_matches_any_value():
30+
assert _field_matches('*', 0) is True
31+
assert _field_matches('*', 59) is True
32+
assert _field_matches(' * ', 7) is True
33+
34+
35+
def test_field_matches_exact_value():
36+
assert _field_matches('5', 5) is True
37+
assert _field_matches('5', 6) is False
38+
39+
40+
def test_field_matches_range_inclusive():
41+
assert _field_matches('1-5', 1) is True
42+
assert _field_matches('1-5', 3) is True
43+
assert _field_matches('1-5', 5) is True
44+
45+
46+
def test_field_matches_range_outside():
47+
assert _field_matches('1-5', 0) is False
48+
assert _field_matches('1-5', 6) is False
49+
50+
51+
def test_field_matches_comma_list():
52+
assert _field_matches('1,3,5', 1) is True
53+
assert _field_matches('1,3,5', 3) is True
54+
assert _field_matches('1,3,5', 5) is True
55+
assert _field_matches('1,3,5', 2) is False
56+
assert _field_matches('1,3,5', 4) is False
57+
58+
59+
def test_field_matches_malformed_range_returns_false():
60+
assert _field_matches('1-', 1) is False
61+
assert _field_matches('1-', 0) is False
62+
assert _field_matches('-5', 3) is False
63+
64+
65+
def test_field_matches_non_numeric_returns_false():
66+
assert _field_matches('abc', 1) is False
67+
68+
69+
def test_cron_matches_now_short_expression_returns_false():
70+
assert cron_matches_now('* * * *', FIXED_TS) is False
71+
72+
73+
@requires_tzset
74+
def test_cron_matches_now_matching_expression(utc_tz):
75+
assert cron_matches_now('13 22 14 11 *', FIXED_TS) is True
76+
77+
78+
@requires_tzset
79+
def test_cron_matches_now_matching_dow(utc_tz):
80+
assert cron_matches_now('13 22 * * 2', FIXED_TS) is True
81+
82+
83+
@requires_tzset
84+
def test_cron_matches_now_non_matching_minute(utc_tz):
85+
assert cron_matches_now('14 22 14 11 *', FIXED_TS) is False
86+
87+
88+
@requires_tzset
89+
def test_cron_matches_now_dom_dow_either_matches(utc_tz):
90+
assert cron_matches_now('13 22 1 11 2', FIXED_TS) is True
91+
assert cron_matches_now('13 22 14 11 5', FIXED_TS) is True
92+
assert cron_matches_now('13 22 1 11 5', FIXED_TS) is False

0 commit comments

Comments
 (0)