Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/7902.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed RedisWorker leaking Redis locks when task acquire, abort, cancel, or immediate dispatch cleanup failed.
2 changes: 2 additions & 0 deletions pulpcore/tasking/redis_locks.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ def release_resource_locks(

except redis.RedisError as e:
_logger.error("Error releasing locks: %s", e)
raise


async def async_release_resource_locks(
Expand Down Expand Up @@ -439,3 +440,4 @@ async def async_release_resource_locks(

except redis.RedisError as e:
_logger.error("Error releasing locks: %s", e)
raise
63 changes: 61 additions & 2 deletions pulpcore/tasking/redis_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
Redis distributed locks for task coordination.
"""

import asyncio
import contextvars
import logging
import time

import redis
from asgiref.sync import sync_to_async
Expand All @@ -19,6 +21,7 @@
async_safe_release_task_locks,
extract_task_resources,
get_task_lock_key,
release_resource_locks,
safe_release_task_locks,
)
from pulpcore.tasking.tasks import (
Expand Down Expand Up @@ -103,6 +106,61 @@ def clear_cancel_signal(task_id):
_logger.error("Error clearing cancellation signal for task %s: %s", task_id, e)


def _release_task_locks_any_owner(task):
redis_conn = get_redis_connection()
if redis_conn is None:
return
task_lock_key = get_task_lock_key(task.pk)
try:
lock_owner = redis_conn.get(task_lock_key)
except redis.RedisError as e:
_logger.error("Error reading task lock for %s: %s", task.pk, e)
return
if not lock_owner:
return
if isinstance(lock_owner, bytes):
lock_owner = lock_owner.decode()
exclusive_resources, shared_resources = extract_task_resources(task)
try:
release_resource_locks(
redis_conn, lock_owner, task_lock_key, exclusive_resources, shared_resources
)
except redis.RedisError as e:
_logger.error("Error releasing locks for canceled task %s: %s", task.pk, e)


def _retry_safe_release_task_locks(task, lock_owner, attempts=3):
for attempt in range(attempts):
try:
safe_release_task_locks(task, lock_owner)
return
except redis.RedisError:
if attempt < attempts - 1:
time.sleep(0.1 * (attempt + 1))
else:
_logger.error(
"Failed to release locks for task %s after %s attempts.",
task.pk,
attempts,
)


async def _aretry_safe_release_task_locks(task, lock_owner, attempts=3):
for attempt in range(attempts):
try:
await async_safe_release_task_locks(task, lock_owner)
return
except redis.RedisError:
if attempt < attempts - 1:
await asyncio.sleep(0.1 * (attempt + 1))
else:
_logger.error(
"Failed to release locks for task %s after %s attempts.",
task.pk,
attempts,
)


def cancel_task(task_id):
"""
Cancel a task using Redis-based signaling.
Expand Down Expand Up @@ -142,6 +200,7 @@ def cancel_task(task_id):
Task.objects.filter(pk=task.pk).update(app_lock=AppStatus.objects.current())
task.app_lock = AppStatus.objects.current()
task.set_canceled()
_release_task_locks_any_owner(task)
else:
# Task is RUNNING — signal the supervising worker.
publish_cancel_signal(task.pk)
Expand Down Expand Up @@ -390,7 +449,7 @@ def dispatch(
except Exception:
# Release locks if using_workdir() failed before
# execute_task() had a chance to run and release them
safe_release_task_locks(task, lock_owner)
_retry_safe_release_task_locks(task, lock_owner)
raise
elif deferred:
# Locks not available, defer to worker
Expand Down Expand Up @@ -442,7 +501,7 @@ async def adispatch(
except Exception:
# Release locks if using_workdir() failed before
# aexecute_task() had a chance to run and release them
await async_safe_release_task_locks(task, lock_owner)
await _aretry_safe_release_task_locks(task, lock_owner)
raise
elif deferred:
# Locks not available, defer to worker
Expand Down
45 changes: 27 additions & 18 deletions pulpcore/tasking/redis_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,10 @@ def fetch_task(self):

except Exception as e:
_logger.error("Error processing task %s: %s", task.pk, e)
try:
safe_release_task_locks(task, lock_owner=self.name)
except Exception:
pass
continue

if len(waiting_tasks) < fetch_limit:
Expand Down Expand Up @@ -668,24 +672,29 @@ def supervise_task(self, task):
if cancel_state:
from pulpcore.tasking._util import delete_incomplete_resources

# Reload task from database to get current state
task.refresh_from_db()
# Only clean up if task is not already in a final state
# (subprocess may have already handled cancellation)
if task.state not in TASK_FINAL_STATES:
# Release locks BEFORE setting canceled state
# Atomically release task lock + resource locks in a single operation
self._maybe_release_locks(task)

task.set_canceling()
_logger.info(
"Cleaning up task %s in domain: %s and marking as %s.",
task.pk,
domain.name,
cancel_state,
)
delete_incomplete_resources(task)
task.set_canceled(final_state=cancel_state, reason=cancel_reason)
try:
# Reload task from database to get current state
task.refresh_from_db()
# Only clean up if task is not already in a final state
# (subprocess may have already handled cancellation)
if task.state not in TASK_FINAL_STATES:
# Release locks BEFORE setting canceled state
self._maybe_release_locks(task)
task.set_canceling()
_logger.info(
"Cleaning up task %s in domain: %s and marking as %s.",
task.pk,
domain.name,
cancel_state,
)
delete_incomplete_resources(task)
task.set_canceled(final_state=cancel_state, reason=cancel_reason)
except Exception:
_logger.exception("Error in cancel path for task %s", task.pk)
try:
self._maybe_release_locks(task)
except Exception:
_logger.exception("Failed to release locks for task %s", task.pk)
Comment on lines +692 to +697

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkliban the _maybe_release_locks is now within if task.state not in TASK_FINAL_STATES: as well as within the Exception section.

I added an extra try/except block here just in case the "release lock" generates an exception, this way the exception is not propagated


self.task = None

Expand Down
Loading