Skip to content

Commit 6c90608

Browse files
committed
Fix stale cron lock files blocking scheduled feed updates (#161)
Replace the PID-file locking in feed_updater and cleanup_entries with a kernel-managed flock (LOCK_EX|LOCK_NB). The old check only tested whether /tmp/update_feeds_<frequency>.lock existed, so a process killed before its finally-block ran (OOM kill, container restart) left a stale lock behind that silently skipped every subsequent run of that frequency. The flock is released automatically by the kernel when the process exits for any reason, so stale locks can no longer occur; the lock file now only records the holder PID for debugging.
1 parent b458614 commit 6c90608

4 files changed

Lines changed: 204 additions & 178 deletions

File tree

core/management/commands/cleanup_entries.py

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Remove entries greater than feed.max_posts
22

3+
import fcntl
34
import os
45
import sys
56
import time
@@ -19,29 +20,34 @@ class Command(BaseCommand):
1920
def handle(self, *args, **options):
2021
lock_file_path = "/tmp/cleanup_entries.lock"
2122

22-
if os.path.exists(lock_file_path):
23-
self.stdout.write(
24-
self.style.WARNING(
25-
f"{current_time}: Cleanup process is already running. Exiting."
23+
# Kernel-managed flock: released automatically on process exit (even on
24+
# crash/OOM/container stop), so it can never go stale (see issue #161).
25+
with open(lock_file_path, "w") as lock_file:
26+
try:
27+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
28+
except BlockingIOError:
29+
self.stdout.write(
30+
self.style.WARNING(
31+
f"{current_time}: Cleanup process is already running. Exiting."
32+
)
2633
)
27-
)
28-
sys.exit(0)
29-
30-
try:
31-
with open(lock_file_path, "w") as f:
32-
f.write(str(os.getpid()))
33-
34-
cleanup_all_feeds()
35-
self.stdout.write(
36-
self.style.SUCCESS(f"{current_time}: Successfully cleaned up all feeds")
37-
)
38-
except Exception as e:
39-
logger.exception(f"Command cleanup_entries failed: {str(e)}")
40-
self.stderr.write(self.style.ERROR(f"Error: {str(e)}"))
41-
sys.exit(1)
42-
finally:
43-
if os.path.exists(lock_file_path):
44-
os.remove(lock_file_path)
34+
sys.exit(0)
35+
36+
# Record the holder PID for debugging only; the flock itself is the lock.
37+
lock_file.write(str(os.getpid()))
38+
lock_file.flush()
39+
40+
try:
41+
cleanup_all_feeds()
42+
self.stdout.write(
43+
self.style.SUCCESS(
44+
f"{current_time}: Successfully cleaned up all feeds"
45+
)
46+
)
47+
except Exception as e:
48+
logger.exception(f"Command cleanup_entries failed: {str(e)}")
49+
self.stderr.write(self.style.ERROR(f"Error: {str(e)}"))
50+
sys.exit(1)
4551

4652

4753
def cleanup_feed_entries(feed: Feed):

core/management/commands/feed_updater.py

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fcntl
12
import logging
23
import sys
34
from itertools import chain
@@ -52,33 +53,37 @@ def handle(self, *args, **options):
5253

5354
lock_file_path = f"/tmp/update_feeds_{target_frequency.replace(' ', '_')}.lock"
5455

55-
if os.path.exists(lock_file_path):
56-
self.stdout.write(
57-
self.style.WARNING(
58-
f"{current_time}: Another update process for frequency '{target_frequency}' is already running. Exiting."
56+
# Use a kernel-managed flock instead of a plain PID file: the lock is
57+
# released automatically when the process exits for ANY reason (crash,
58+
# OOM kill, container stop), so it can never go stale and block all
59+
# future runs of this frequency (issue #161).
60+
with open(lock_file_path, "w") as lock_file:
61+
try:
62+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
63+
except BlockingIOError:
64+
self.stdout.write(
65+
self.style.WARNING(
66+
f"{current_time}: Another update process for frequency '{target_frequency}' is already running. Exiting."
67+
)
5968
)
60-
)
61-
sys.exit(0)
69+
sys.exit(0)
6270

63-
try:
64-
# Create lock file
65-
with open(lock_file_path, "w") as f:
66-
f.write(str(os.getpid()))
67-
68-
update_feeds_for_frequency(simple_update_frequency=target_frequency)
69-
self.stdout.write(
70-
self.style.SUCCESS(
71-
f"{current_time}: Successfully updated feeds for frequency: {target_frequency}"
71+
# Record the holder PID for debugging only; the flock, not this
72+
# file's existence, is what actually excludes other processes.
73+
lock_file.write(str(os.getpid()))
74+
lock_file.flush()
75+
76+
try:
77+
update_feeds_for_frequency(simple_update_frequency=target_frequency)
78+
self.stdout.write(
79+
self.style.SUCCESS(
80+
f"{current_time}: Successfully updated feeds for frequency: {target_frequency}"
81+
)
7282
)
73-
)
74-
except Exception as e:
75-
logger.exception(f"Command update_feeds_for_frequency failed: {str(e)}")
76-
self.stderr.write(self.style.ERROR(f"Error: {str(e)}"))
77-
sys.exit(1)
78-
finally:
79-
# Ensure lock file is removed
80-
if os.path.exists(lock_file_path):
81-
os.remove(lock_file_path)
83+
except Exception as e:
84+
logger.exception(f"Command update_feeds_for_frequency failed: {str(e)}")
85+
self.stderr.write(self.style.ERROR(f"Error: {str(e)}"))
86+
sys.exit(1)
8287

8388

8489
def update_single_feed(feed: Feed):

core/tests/test_cleanup_entries.py

Lines changed: 58 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.test import TestCase, override_settings
22
from unittest.mock import patch, Mock, mock_open, call
3+
import fcntl
34
import os
45
import tempfile
56
import shutil
@@ -244,24 +245,11 @@ def setUp(self):
244245
original_content=f"Content {i}",
245246
)
246247

247-
@patch("core.management.commands.cleanup_entries.os.path.exists")
248+
@patch("core.management.commands.cleanup_entries.fcntl")
248249
@patch("core.management.commands.cleanup_entries.open", new_callable=mock_open)
249250
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
250-
@patch("core.management.commands.cleanup_entries.os.remove")
251-
def test_command_success(self, mock_remove, mock_cleanup, mock_file, mock_exists):
251+
def test_command_success(self, mock_cleanup, mock_file, mock_fcntl):
252252
"""Test successful command execution."""
253-
# Mock lock file doesn't exist initially, but exists after creation
254-
mock_exists.side_effect = [False, True]
255-
256-
# Mock file operations
257-
mock_file.return_value.__enter__.return_value.write.return_value = None
258-
259-
# Mock cleanup function
260-
mock_cleanup.return_value = None
261-
262-
# Mock os.remove
263-
mock_remove.return_value = None
264-
265253
# Capture stdout
266254
from io import StringIO
267255

@@ -274,18 +262,18 @@ def test_command_success(self, mock_remove, mock_cleanup, mock_file, mock_exists
274262
# Verify cleanup was called
275263
mock_cleanup.assert_called_once()
276264

277-
# Verify lock file was created and removed
278-
mock_file.assert_called_once()
279-
mock_remove.assert_called_once()
265+
# Verify the flock was acquired
266+
mock_fcntl.flock.assert_called_once()
280267

281268
# Verify success message
282269
self.assertIn("Successfully cleaned up all feeds", out.getvalue())
283270

284-
@patch("core.management.commands.cleanup_entries.os.path.exists")
285-
def test_command_already_running(self, mock_exists):
286-
"""Test command when cleanup is already running."""
287-
# Mock lock file exists
288-
mock_exists.return_value = True
271+
@patch("core.management.commands.cleanup_entries.fcntl")
272+
@patch("core.management.commands.cleanup_entries.open", new_callable=mock_open)
273+
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
274+
def test_command_already_running(self, mock_cleanup, mock_file, mock_fcntl):
275+
"""Test command when cleanup is already running (flock held elsewhere)."""
276+
mock_fcntl.flock.side_effect = BlockingIOError()
289277

290278
# Capture stdout
291279
from io import StringIO
@@ -303,30 +291,20 @@ def test_command_already_running(self, mock_exists):
303291
# Verify warning message
304292
self.assertIn("Cleanup process is already running", out.getvalue())
305293

306-
@patch("core.management.commands.cleanup_entries.os.path.exists")
294+
# Verify cleanup did not run
295+
mock_cleanup.assert_not_called()
296+
297+
@patch("core.management.commands.cleanup_entries.fcntl")
307298
@patch("core.management.commands.cleanup_entries.open", new_callable=mock_open)
308299
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
309-
@patch("core.management.commands.cleanup_entries.os.remove")
310300
@patch("core.management.commands.cleanup_entries.logger")
311301
def test_command_exception_handling(
312-
self, mock_logger, mock_remove, mock_cleanup, mock_file, mock_exists
302+
self, mock_logger, mock_cleanup, mock_file, mock_fcntl
313303
):
314304
"""Test command exception handling."""
315-
# Mock lock file doesn't exist initially, but exists after creation
316-
mock_exists.side_effect = [False, True]
317-
318-
# Mock file operations
319-
mock_file.return_value.__enter__.return_value.write.return_value = None
320-
321305
# Mock cleanup function to raise exception
322306
mock_cleanup.side_effect = Exception("Test error")
323307

324-
# Mock os.remove
325-
mock_remove.return_value = None
326-
327-
# Mock logger to suppress output
328-
mock_logger.exception.return_value = None
329-
330308
# Capture stderr
331309
from io import StringIO
332310

@@ -343,42 +321,54 @@ def test_command_exception_handling(
343321
# Verify error message
344322
self.assertIn("Test error", err.getvalue())
345323

346-
# Verify lock file was still removed
347-
mock_remove.assert_called_once()
348324

349-
@patch("core.management.commands.cleanup_entries.os.path.exists")
350-
@patch("core.management.commands.cleanup_entries.open", new_callable=mock_open)
351-
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
352-
@patch("core.management.commands.cleanup_entries.os.remove")
353-
@patch("core.management.commands.cleanup_entries.logger")
354-
def test_command_lock_file_cleanup_on_exception(
355-
self, mock_logger, mock_remove, mock_cleanup, mock_file, mock_exists
356-
):
357-
"""Test that lock file is cleaned up even when exception occurs."""
358-
# Mock lock file doesn't exist initially, but exists after creation
359-
mock_exists.side_effect = [False, True]
325+
class CleanupEntriesLockRegressionTests(TestCase):
326+
"""Regression tests for issue #161 using a real lock file on disk."""
360327

361-
# Mock file operations
362-
mock_file.return_value.__enter__.return_value.write.return_value = None
328+
lock_path = "/tmp/cleanup_entries.lock"
363329

364-
# Mock cleanup function to raise exception
365-
mock_cleanup.side_effect = Exception("Test error")
330+
def tearDown(self):
331+
if os.path.exists(self.lock_path):
332+
os.remove(self.lock_path)
366333

367-
# Mock os.remove
368-
mock_remove.return_value = None
334+
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
335+
def test_stale_lock_file_does_not_block_cleanup(self, mock_cleanup):
336+
"""A leftover lock file from a dead process must not block the next run."""
337+
# Simulate the stale lock left behind by a killed process (issue #161):
338+
# the file exists and contains a dead PID, but nobody holds the flock.
339+
with open(self.lock_path, "w") as f:
340+
f.write("999999")
369341

370-
# Mock logger to suppress output
371-
mock_logger.exception.return_value = None
342+
command = Command()
343+
with patch.object(command, "stdout"), patch.object(command, "stderr"):
344+
command.handle()
372345

373-
# Mock stderr to suppress error output
374-
from io import StringIO
346+
mock_cleanup.assert_called_once()
375347

376-
mock_stderr = StringIO()
377-
self.command.stderr = mock_stderr
348+
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
349+
def test_lock_held_by_live_process_blocks_cleanup(self, mock_cleanup):
350+
"""While a live process holds the flock, the command exits without cleaning."""
351+
with open(self.lock_path, "w") as holder:
352+
fcntl.flock(holder.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
378353

379-
# Execute command
380-
with self.assertRaises(SystemExit):
381-
self.command.handle()
354+
command = Command()
355+
with patch.object(command, "stdout"), patch.object(command, "stderr"):
356+
with self.assertRaises(SystemExit) as ctx:
357+
command.handle()
358+
self.assertEqual(ctx.exception.code, 0)
359+
360+
mock_cleanup.assert_not_called()
382361

383-
# Verify lock file was removed in finally block
384-
mock_remove.assert_called_once()
362+
@patch("core.management.commands.cleanup_entries.cleanup_all_feeds")
363+
def test_lock_released_after_failed_run(self, mock_cleanup):
364+
"""A failed run releases the lock, so the next run still proceeds."""
365+
command = Command()
366+
with patch.object(command, "stdout"), patch.object(command, "stderr"):
367+
mock_cleanup.side_effect = Exception("boom")
368+
with self.assertRaises(SystemExit):
369+
command.handle()
370+
371+
mock_cleanup.side_effect = None
372+
command.handle()
373+
374+
self.assertEqual(mock_cleanup.call_count, 2)

0 commit comments

Comments
 (0)