Implemented draft of new background task system

This commit is contained in:
jtc42 2019-11-01 16:06:49 +00:00
parent 7c121cd142
commit e4a78046d7
11 changed files with 370 additions and 159 deletions

View file

@ -1,8 +1,10 @@
from openflexure_microscope.api.v1.views import MicroscopeView from openflexure_microscope.api.v1.views import MethodView
from flask import jsonify, abort, Blueprint from flask import jsonify, abort, Blueprint
from openflexure_microscope.common import tasks
class TaskListAPI(MicroscopeView):
class TaskListAPI(MethodView):
def get(self): def get(self):
""" """
Get list of long-running tasks. Get list of long-running tasks.
@ -43,7 +45,7 @@ class TaskListAPI(MicroscopeView):
:>header Content-Type: application/json :>header Content-Type: application/json
""" """
data = self.microscope.task.state data = tasks.states()
return jsonify(data) return jsonify(data)
@ -58,13 +60,13 @@ class TaskListAPI(MicroscopeView):
:>header Content-Type: application/json :>header Content-Type: application/json
""" """
self.microscope.task.clean() tasks.cleanup_tasks()
data = self.microscope.task.state data = tasks.states()
return jsonify(data) return jsonify(data)
class TaskAPI(MicroscopeView): class TaskAPI(MethodView):
def get(self, task_id): def get(self, task_id):
""" """
Get JSON representation of a task Get JSON representation of a task
@ -99,9 +101,9 @@ class TaskAPI(MicroscopeView):
""" """
task = self.microscope.task.task_from_id(task_id) try:
task = tasks.tasks()[task_id]
if not task_id: except KeyError:
return abort(404) # 404 Not Found return abort(404) # 404 Not Found
# Return task state # Return task state
@ -109,26 +111,23 @@ class TaskAPI(MicroscopeView):
def delete(self, task_id): def delete(self, task_id):
""" """
Remove a particular task (fails if task is currently running). Terminate a particular task.
.. :quickref: Tasks; Remove a task .. :quickref: Tasks; Terminate a task
:>header Accept: application/json :>header Accept: application/json
:>header Content-Type: application/json :>header Content-Type: application/json
""" """
success = self.microscope.task.delete(task_id) try:
task = tasks.tasks()[task_id]
except KeyError:
return abort(404) # 404 Not Found
if success: task.terminate()
data = {"status": "success", "message": "task successfully deleted"}
else:
data = {
"status": "fail",
"message": "task could not be deleted, as it is currently running or does not exist",
}
return jsonify(data) return jsonify(task.state)
def construct_blueprint(microscope_obj): def construct_blueprint(microscope_obj):
@ -136,11 +135,11 @@ def construct_blueprint(microscope_obj):
blueprint = Blueprint("task_blueprint", __name__) blueprint = Blueprint("task_blueprint", __name__)
blueprint.add_url_rule( blueprint.add_url_rule(
"/", view_func=TaskListAPI.as_view("task_list", microscope=microscope_obj) "/", view_func=TaskListAPI.as_view("task_list")
) )
blueprint.add_url_rule( blueprint.add_url_rule(
"/<task_id>/", view_func=TaskAPI.as_view("task", microscope=microscope_obj) "/<task_id>/", view_func=TaskAPI.as_view("task")
) )
return blueprint return blueprint

View file

@ -0,0 +1,2 @@
from .pool import tasks, states, current_task, update_task_progress, cleanup_tasks, remove_task, update_task_data, taskify
from .thread import ThreadTerminationError

View file

@ -0,0 +1,118 @@
import threading
import logging
from functools import wraps
from .thread import TaskThread
class TaskMaster:
def __init__(self, *args, **kwargs):
self._tasks = []
@property
def tasks(self):
"""
Returns:
dict: Dictionary of TaskThread objects. Key is TaskThread ID.
"""
return {t.id: t for t in self._tasks}
@property
def states(self):
"""
Returns:
dict: Dictionary of TaskThread.state dictionaries. Key is TaskThread ID.
"""
return {t.id: t.state for t in self._tasks}
def new(self, f, *args, **kwargs):
task = TaskThread(target=f, args=args, kwargs=kwargs)
self._tasks.append(task)
return task
def remove(self, task_id):
for task in self._tasks:
if (task.id == task_id) and not task.isAlive():
del task
def cleanup(self):
for task in self._tasks:
if not task.isAlive():
del task
# Task management
def tasks():
"""
Dictionary of tasks in default taskmaster
Returns:
dict: Dictionary of tasks in default taskmaster
"""
global _default_task_master
return _default_task_master.tasks
def states():
"""
Dictionary of TaskThread.state dictionaries. Key is TaskThread ID.
Returns:
dict: Dictionary of task states in default taskmaster
"""
global _default_task_master
return _default_task_master.states
def cleanup_tasks():
global _default_task_master
return _default_task_master.cleanup()
def remove_task(task_id: str):
global _default_task_master
return _default_task_master.remove(task_id)
# Operations on the current task
def current_task():
current_task_thread = threading.current_thread()
if not isinstance(current_task_thread, TaskThread):
return None
return current_task_thread
def update_task_progress(progress: int):
if current_task():
current_task().update_progress(progress)
else:
logging.info("Cannot update task progress of __main__ thread. Skipping.")
def update_task_data(data: dict):
if current_task():
current_task().update_data(data)
else:
logging.info("Cannot update task data of __main__ thread. Skipping.")
# Main "taskify" functions
def taskify(f):
"""
A decorator that wraps the passed in function
and surpresses exceptions should one occur
"""
@wraps(f)
def wrapped(*args, **kwargs):
task = _default_task_master.new(
f, *args, **kwargs
) # Append to parent object's task list
task.start() # Start the function
return task
return wrapped
# Create our default, protected, module-level task pool
_default_task_master = TaskMaster()

View file

@ -0,0 +1,200 @@
import ctypes
import datetime
import logging
import traceback
import uuid
import threading
_LOG = logging.getLogger(__name__)
class ThreadTerminationError(SystemExit):
"""Sibling of SystemExit, but specific to thread termination."""
class TaskThread(threading.Thread):
def __init__(self, target=None, name=None, args=(), kwargs={}, daemon=True):
threading.Thread.__init__(
self,
group=None,
target=target,
name=name,
args=args,
kwargs=kwargs,
daemon=daemon,
)
# A UUID for the TaskThread (not the same as the threading.Thread ident)
self._ID = uuid.uuid4().hex # Task ID
# Make _target, _args, and _kwargs available to the subclass
self._target = target
self._args = args
self._kwargs = kwargs
# Nice string representation of target function
self.target_string = f"{self._target}(args={self._args}, kwargs={self._kwargs})"
# Private state properties
self._status: str = "idle" # Task status
self._return_value = None # Return value
self._start_time = None # Task start time
self._end_time = None # Task end time
# Public state properties
self.progress: int = None # Percent progress of the task
self.data = {} # Dictionary of custom data added during the task
# Stuff for handling termination
self._running_lock = (
threading.Lock()
) # Lock obtained while self._target is running
self._killed = (
threading.Event()
) # Event triggered when thread is manually terminated
@property
def id(self):
"""
Return ID of current TaskThread
"""
return self._ID
@property
def state(self):
return {
"function": self.target_string,
"id": self._ID,
"status": self._status,
"progress": self.progress,
"data": self.data,
"return": self._return_value,
"start_time": self._start_time,
"end_time": self._end_time,
}
def update_progress(self, progress: int):
# Update progress of the task
self.progress = progress
def update_data(self, data: dict):
# Store data to be used before task finishes (eg for real-time plotting)
self.data.update(data)
def _thread_proc(self, f):
"""
Wraps the target function to handle recording `status` and `return` to `state`.
Happens inside the task thread.
"""
def wrapped(*args, **kwargs):
nonlocal self
self._status = "running"
self._start_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
try:
self._return_value = f(*args, **kwargs)
self._status = "success"
except Exception as e:
logging.error(e)
logging.error(traceback.format_exc())
self._return_value = str(e)
self._status = "error"
finally:
self._end_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
return wrapped
def run(self):
"""
Overrides default threading.Thread run() method
"""
logging.debug((self._args, self._kwargs))
try:
with self._running_lock:
if self._killed.is_set():
raise ThreadTerminationError()
if self._target:
self._thread_proc(self._target)(*self._args, **self._kwargs)
finally:
# Avoid a refcycle if the thread is running a function with
# an argument that has a member that points to the thread.
del self._target, self._args, self._kwargs
def wait(self):
"""
Start waiting for the task to finish before returning
"""
print("Joining thread {}".format(self))
self.join()
return self._return_value
def async_raise(self, exc_type):
"""Raise an exception in this thread."""
# Should only be called on a started thread, so raise otherwise.
assert self.ident is not None, "Only started threads have thread identifier"
# If the thread has died we don't want to raise an exception so log.
if not self.is_alive():
_LOG.debug(
"Not raising %s because thread %s (%s) is not alive",
exc_type,
self.name,
self.ident,
)
return
result = ctypes.pythonapi.PyThreadState_SetAsyncExc(
ctypes.c_long(self.ident), ctypes.py_object(exc_type)
)
if result == 0 and self.is_alive():
# Don't raise an exception an error unnecessarily if the thread is dead.
raise ValueError("Thread ID was invalid.", self.ident)
elif result > 1:
# Something bad happened, call with a NULL exception to undo.
ctypes.pythonapi.PyThreadState_SetAsyncExc(self.ident, None)
raise RuntimeError(
"Error: PyThreadState_SetAsyncExc %s %s (%s) %s"
% (exc_type, self.name, self.ident, result)
)
def _is_thread_proc_running(self):
"""
Test if thread funtion (_thread_proc) is running,
by attemtping to acquire the lock _thread_proc acquires at runtime.
Returns:
bool: If _thread_proc is currently running
"""
could_acquire = self._running_lock.acquire(0)
if could_acquire:
self._running_lock.release()
return False
return True
def terminate(self):
"""
Raise ThreadTerminatedException in the context of the given thread,
which should cause the thread to exit silently.
"""
_LOG.warning(f"Terminating thread {self}")
self._killed.set()
if not self.is_alive():
logging.debug("Cannot kill thread that is no longer running.")
return
if not self._is_thread_proc_running():
logging.debug(
"Thread's _thread_proc function is no longer running, "
"will not kill; letting thread exit gracefully."
)
return
self.async_raise(ThreadTerminationError)
# Wait for the thread for finish closing. If the threaded function has cleanup code in a try-except,
# this pause allows it to finish running before the main process can continue.
while self._is_thread_proc_running():
pass
# Set state to terminated
self._status = "terminated"
self.progress = None

View file

@ -10,6 +10,9 @@ from openflexure_microscope.plugins import MicroscopePlugin
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from openflexure_microscope.api.utilities import JsonResponse from openflexure_microscope.api.utilities import JsonResponse
# Task management
from openflexure_microscope.common.tasks import current_task, update_task_progress, update_task_data, taskify
# Exceptions # Exceptions
from openflexure_microscope.exceptions import TaskDeniedException from openflexure_microscope.exceptions import TaskDeniedException

View file

@ -6,6 +6,7 @@ from openflexure_microscope.devel import (
JsonResponse, JsonResponse,
request, request,
jsonify, jsonify,
taskify
) )
@ -23,7 +24,7 @@ class AutofocusAPI(MicroscopeViewPlugin):
dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array) dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array)
logging.info("Running autofocus...") logging.info("Running autofocus...")
task = self.microscope.task.start(self.plugin.autofocus, dz) task = taskify(self.plugin.autofocus)(dz)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 202
@ -40,9 +41,7 @@ class FastAutofocusAPI(MicroscopeViewPlugin):
backlash = 0 backlash = 0
logging.info("Running autofocus...") logging.info("Running autofocus...")
task = self.microscope.task.start( task = taskify(self.plugin.fast_autofocus)(dz, backlash=backlash)
self.plugin.fast_autofocus, dz, backlash=backlash
)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 202

View file

@ -4,6 +4,7 @@ from openflexure_microscope.devel import (
JsonResponse, JsonResponse,
request, request,
jsonify, jsonify,
taskify
) )
import logging import logging
@ -14,7 +15,7 @@ from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settin
class RecalibrateAPIView(MicroscopeViewPlugin): class RecalibrateAPIView(MicroscopeViewPlugin):
def post(self): def post(self):
logging.info("Starting microscope recalibration...") logging.info("Starting microscope recalibration...")
task = self.microscope.task.start(self.plugin.recalibrate) task = taskify(self.plugin.recalibrate)
# Return a handle on the autofocus task # Return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 202

View file

@ -4,6 +4,7 @@ from openflexure_microscope.devel import (
request, request,
jsonify, jsonify,
abort, abort,
taskify
) )
import logging import logging
@ -43,8 +44,7 @@ class TileScanAPI(MicroscopeViewPlugin):
tags = payload.param("tags", default=[], convert=list) tags = payload.param("tags", default=[], convert=list)
logging.info("Running tile scan...") logging.info("Running tile scan...")
task = self.microscope.task.start( task = taskify(self.plugin.tile)(
self.plugin.tile,
basename=filename, basename=filename,
temporary=temporary, temporary=temporary,
step_size=step_size, step_size=step_size,
@ -59,5 +59,5 @@ class TileScanAPI(MicroscopeViewPlugin):
tags=tags, tags=tags,
) )
# return a handle on the autofocus task # return a handle on the scan task
return jsonify(task.state), 202 return jsonify(task.state), 202

View file

@ -1,11 +1,9 @@
from openflexure_microscope.devel import ( from openflexure_microscope.devel import (
MicroscopeViewPlugin, MicroscopeViewPlugin,
TaskDeniedException,
JsonResponse, JsonResponse,
Response,
request, request,
escape,
jsonify, jsonify,
taskify
) )
import logging import logging
@ -56,9 +54,7 @@ class TaskAPI(MicroscopeViewPlugin):
val_int = payload.param("run_time", default=5, convert=int) val_int = payload.param("run_time", default=5, convert=int)
logging.info("Running task...") logging.info("Running task...")
task = self.microscope.task.start( task = taskify(self.plugin.generate_random_numbers_for_a_while)(val_int)
self.plugin.generate_random_numbers_for_a_while, val_int
)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 202

View file

@ -1,16 +1,10 @@
from threading import Thread
import datetime
import logging import logging
import traceback from openflexure_microscope.common.tasks import tasks, states, taskify, cleanup_tasks, remove_task
import uuid
from openflexure_microscope.exceptions import TaskDeniedException
from openflexure_microscope.utilities import entry_by_id
class TaskOrchestrator: class TaskOrchestrator:
""" """
Class responsible for spawning threaded tasks, and storing their returns. DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns.
A microscope should contain exactly one instance of `TaskOrchestrator`. A microscope should contain exactly one instance of `TaskOrchestrator`.
Attributes: Attributes:
@ -18,26 +12,24 @@ class TaskOrchestrator:
""" """
def __init__(self): @property
self.tasks = [] def tasks(self):
logging.warning("TaskOrchestrator class is deprecated. Please use common.tasks.tasks().values() instead.")
return tasks().values()
@property @property
def state(self): def state(self):
""" """
Returns a list of dictionary representations of all tasks in the session. Returns a list of dictionary representations of all tasks in the session.
""" """
state = {} logging.warning("TaskOrchestrator class is deprecated. Please use common.tasks.tasks() instead.")
return states()
for task in self.tasks:
state[task.id] = task.state
return state
def task_from_id(self, task_id: str): def task_from_id(self, task_id: str):
""" """
Returns a particular task object with the specified `task_id`. Returns a particular task object with the specified `task_id`.
""" """
return entry_by_id(task_id, self.tasks) return tasks()[task_id]
def start(self, function, *args, **kwargs): def start(self, function, *args, **kwargs):
""" """
@ -49,119 +41,20 @@ class TaskOrchestrator:
function (function): The target function to run in the background function (function): The target function to run in the background
args, kwargs: Arguments that will be passed to `function` at runtime. args, kwargs: Arguments that will be passed to `function` at runtime.
""" """
# Create a task object logging.warning("TaskOrchestrator class is deprecated. Please use common.tasks.taskify() instead.")
task = Task(function, *args, **kwargs) return taskify(function)(*args, **kwargs)
# If the task isn't allowed to run
if not self.start_condition(task):
raise TaskDeniedException("Unable to start this task. Aborting.")
else:
self.tasks.append(task)
task.start()
return task
def clean(self): def clean(self):
""" """
Remove all inactive tasks from the task array. Remove all inactive tasks from the task array.
This will remove tasks that either finished or never started. This will remove tasks that either finished or never started.
""" """
self.tasks = [task for task in self.tasks if task._running] logging.warning("TaskOrchestrator class is deprecated. Please use common.tasks.cleanup_tasks() instead.")
cleanup_tasks()
def delete(self, task_id): def delete(self, task_id: str):
""" """
Delete a given task by ID, only if task is not currently running. Delete a given task by ID, only if task is not currently running.
""" """
success = False logging.warning("TaskOrchestrator class is deprecated. Please use common.tasks.remove_task() instead.")
remove_task(task_id)
for task in self.tasks:
if task.id == task_id and not task._running:
self.tasks.remove(task)
success = True
return success
def start_condition(self, task_obj):
"""
Method returning bool describing if a particular task is allowed to run.
Currently always allows tasks to start, as locks determine access to hardware.
"""
# return not any([task._running for task in self.tasks])
return True
class Task:
"""
Class responsible for running a task function in a thread, and handling return or errors.
Tasks should be created by an instance of :py:class:`openflexure_microscope.task.TaskOrchestrator`.
Args:
function (function): Function to be called in the task thread.
Attributes:
task (function): Function to be called in the task thread.
args: Positional arguments to be passed to the task function
kwargs: Keyword arguments to be passed to the task function.
_running (bool): If task is currently running
id (str): Unique ID for the task
state (dict): Dictionary describing the full state of the task.
`status` will be one of 'idle'', 'running', 'error', or 'success'.
`return` eventually holds the return value of the task function.
"""
def __init__(self, function, *args, **kwargs):
# The task long-running method
self.task = function
self.args = args
self.kwargs = kwargs
self._running = False
self.id = uuid.uuid4().hex
self.state = {
"id": self.id,
"status": "idle",
"return": None,
"start_time": None,
"end_time": None,
}
def process(self, f):
"""
Wraps the target function to handle recording `status` and `return` to `state`.
"""
def wrapped(*args, **kwargs):
self.state["status"] = "running"
try:
r = f(*args, **kwargs)
s = "success"
except Exception as e:
logging.error(e)
logging.error(traceback.format_exc())
r = str(e)
s = "error"
self.state["return"] = r
self.state["status"] = s
return wrapped
def run(self):
"""
Records the task start and end times to `state`, and runs the task wrapped in `process`.
"""
self.state["start_time"] = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
self.process(self.task)(*self.args, **self.kwargs)
self._running = False
self.state["end_time"] = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
def start(self): # Start and draw
"""
Spawns a thread, and starts `run()` in that thread.
"""
# If not already running
if not self._running:
self._running = True # Set start command
thread = Thread(target=self.run) # Define thread
thread.daemon = True # Stop this thread when main thread closes
thread.start() # Start thread