Repository navigation
Fix TaskManager submit deadlock and three feed updater bugs - #167
Merged
Merged
Conversation
submit_task called _restart_executor() while holding self.lock, and _restart_executor() runs executor.shutdown(wait=True). Workers need the same lock in _wrap_task to record status, so the 201st submission (default restart_threshold=200) deadlocked: the feed_updater cron hangs forever holding the frequency flock once a frequency has >200 feeds, and the admin "Force update" action, which submits from inside a worker, wedges the whole web-process TaskManager or fails with "cannot join current thread". ThreadPoolExecutor already reuses its worker threads and task history is bounded by _cleanup_tasks, so the restart guards against nothing. Remove restart_threshold, tasks_executed_since_restart and _restart_executor. Add regression tests that submit 250 tasks while an earlier one is still running, both from the main thread and nested inside a worker. They run in a subprocess with a timeout so a deadlock fails instead of hanging the test run.
update_multiple_feeds submitted each feed as update_feed_{feed.name}.
Feed.name is neither unique nor required, and it is filled from the feed
title, so subscribing to one feed_url in several target languages (the
unique key is (feed_url, target_language)) gives feeds with the same
name. TaskManager deduplicates pending/running tasks by name, so only the
first of those feeds was fetched; the others were silently skipped on
every scheduled run but still re-cached.
Use the unique feed.slug, matching the key the admin already uses for
single-feed updates (core/admin/feed_admin.py). Add a regression test
with two same-named feeds that checks both are updated.
When the request itself fails (URLError: DNS failure, connection refused,
connect or TLS handshake timeout), feedparser returns a bozo result with
no "status" key. fetch_feed read feed.status unconditionally, so it raised
AttributeError: the feed log showed "object has no attribute 'status'"
instead of the real error, and the httpx fallback in manual_fetch_feed
was skipped.
Use feed.get("status") so these results go through the existing
bozo-without-entries branch to manual_fetch_feed, which reports the real
error (or recovers when httpx can connect where urllib cannot, e.g.
different SSL/CA handling).
The DummyFeed test fixture now exposes its attributes through get(), as
FeedParserDict does. Add a regression test that fetches from a closed
localhost port and checks the fallback runs and the reported error is the
connection failure.
Command.handle opened the lock file with mode "w" before calling flock, so every competing cron run truncated the file and erased the PID written by the process that holds the lock. Since #166 the lock is held until all feed workers finish, which is when the PID is needed to find a stuck process. Open the file in append mode, which does not truncate, and truncate it only after the lock is acquired, just before writing the PID. Add regression tests: a competing run leaves the holder PID in place, and the new holder replaces stale content.
Deploying rss-translator-home with
|
| Latest commit: |
363bd9b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e495119c.rss-translator-home.pages.dev |
| Branch Preview URL: | https://fix-feed-updater-preexisting.rss-translator-home.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
These are four older bugs in the scheduled feed updater and
TaskManager. I found them while reviewing #166, but #166 didn't introduce any of them. Each fix is a separate commit with a regression test. The branch is rebased ontomain(cdb997d). Every new regression test fails oncdb997d(and on5a74228) and passes on this branch.1. TaskManager deadlock at the 201st submission (high)
Root cause.
submit_taskcalled_restart_executor()while holdingself.lock._restart_executor()callsexecutor.shutdown(wait=True), which joins the worker threads. Each worker needsself.lockin_wrap_taskto record task status, so neither side can proceed. The defaultrestart_threshold=200meant the 201st submission triggered it:feed_updater).Feed.update_frequencydefaults to 30, so most feeds land in one bucket. Once that bucket has more than 200 feeds, the command blocks insubmit_taskforever while holding the frequency flock. That frequency then gets no updates until the container restarts.update_multiple_feedssubmits from inside a TaskManager worker. When those nested submissions cross the threshold, the worker tries to shut down its own pool. Depending on join order, it either deadlocks the whole TaskManager or fails withRuntimeError: cannot join current thread, and the remaining feeds are never submitted.Evidence (real
TaskManagerwith production defaults and the realupdate_multiple_feeds;update_single_feedreplaced by a 50 ms sleep):Fix. Remove the restart mechanism (
restart_threshold,tasks_executed_since_restart,_restart_executor). Nothing ever showed that the restart prevented a memory leak.ThreadPoolExecutoralready reuses its worker threads, and_cleanup_tasksalready capstasks/futuresatmax_task_history. The tests that covered the restart behavior are removed.Tests.
TaskManagerTestCase.test_submit_more_tasks_than_old_restart_threshold_while_runningsubmits 250 tasks while an earlier task is still running.test_nested_submissions_from_worker_do_not_deadlockruns the same burst from inside a worker, which is the "Force update" path. Both run in a subprocess with a 15 s timeout, so a regression fails the test instead of hanging the test runner. A deadlocked executor's non-daemon workers would otherwise block interpreter exit. On5a74228both fail withTaskManager deadlocked during a direct/nested burst of 250 tasks. On this branch each takes about 0.5 s.After the fix, the same probes give 200 feeds in 1.16 s, 201 feeds in 1.21 s and 1000 feeds in 5.80 s. The Force update probe with 250 nested feeds gives
outer done: True,later submit: OK, with no exception logged.2. Same-name feeds are deduplicated in scheduled updates (medium)
Root cause.
update_multiple_feedssubmittedupdate_feed_{feed.name}.Feed.nameis neither unique nor required, and it is filled in from the feed title. The unique key is(feed_url, target_language), so subscribing to one source in several languages produces feeds with the same name.TaskManager.submit_taskreturns the existing future when a task with that name is pending or running. As a result, every same-named feed after the first was skipped on every scheduled run, though it was still re-cached.Evidence.
probe_dup_names.pysubmitted['hn-zh', 'hn-ja']but only updated['hn-zh'].cache_rsswas called for both.Fix. Key the task by
feed.slug, which is unique and set automatically on save. This matches the key the admin single-feed update already uses (core/admin/feed_admin.py:update_feed_{feed.slug}). Two exact-name assertions in existing tests were updated.Test.
UpdateMultipleFeedsTests.test_feeds_with_same_name_are_all_updatedcreates two realFeedrows with the same name and URL in different target languages, then runs them through a realTaskManager. On5a74228only one feed is updated. After the fix the probe updates both (['hn-ja', 'hn-zh']).3.
fetch_feedloses the real error on network failures (low)Root cause. When feedparser hits a
URLError(DNS failure, connection refused, connect or TLS-handshake timeout), it returns a bozo result without astatuskey (feedparser/api.py,parse).fetch_feedreadfeed.statusunconditionally, so it raisedAttributeError. The feed log then showedobject has no attribute 'status', and themanual_fetch_feed(httpx) fallback never ran. Since #166 added fetch timeouts, connect and TLS timeouts end up on this path too.Evidence (
probe_https_connect.py,RSS_FETCH_TIMEOUT=0.5):Fix. Use
feed.get("status") == 304.URLErrorresults now go through the existing "bozo and no entries" branch tomanual_fetch_feed, which reports the real error. The fallback is kept on purpose: httpx and urllib can differ in SSL/CA handling, so the second attempt can succeed. The trade-off is that a feed that is unreachable on both paths takes up to two timeouts to fail instead of one (about 1.0 s instead of 0.5 s in the probe). TheDummyFeedtest fixture now exposes its attributes throughget(), likeFeedParserDict. Without that, its 304 case would no longer be seen.Test.
FetchFeedTests.test_connection_failure_reports_real_error_and_falls_backfetches from a closed localhost port. It checks that the fallback ran and that the error is the connection failure. On5a74228it fails withExpected 'manual_fetch_feed' to have been called once. Called 0 times.4. Competing cron runs erase the lock-holder PID (low)
Root cause.
Command.handleopened the lock file with mode"w"before callingflock. Every competing run therefore truncated the file, even though it then failed to get the lock and exited. Since #166 the lock is held until all workers finish, so the PID is what you need to find a stuck updater, and that is when it was being erased.Evidence. Here a real
Command.handlecompetes against a holder (the lock path is redirected to a temp dir):Fix. Open the file in append mode, which does not truncate. Truncate it only after the lock is acquired, just before writing the PID.
Tests.
UpdateFeedsLockRegressionTests.test_competing_run_keeps_holder_pidfails on5a74228with'' != '4242'.test_holder_pid_replaces_stale_lock_file_contentchecks that the new holder replaces longer stale content. Two existingmock_openassertions now expect"a". The lock-lifetime tests intest_feed_update_regressions.pypass unchanged.Testing
python manage.py testpasses at every commit: 667, 668, 669 and 671 tests OK. The baseline on5a74228is 667 OK.git diff --checkis clean.main(cdb997d, the Keep the updater lock until feed workers finish #166 follow-up); the rebased branch passes all 671 tests.