diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py new file mode 100644 index 0000000000..aadc3d9746 --- /dev/null +++ b/evennia/scripts/taskhandler.py @@ -0,0 +1,188 @@ +""" +Module containing the task handler for Evennia deferred tasks, persistent or not. +""" + +from datetime import datetime, timedelta + +from twisted.internet import reactor, task +from evennia.server.models import ServerConfig +from evennia.utils.logger import log_trace, log_err +from evennia.utils.dbserialize import dbserialize, dbunserialize + +TASK_HANDLER = None + +class TaskHandler(object): + + """ + A light singleton wrapper allowing to access permanent tasks. + + When `utils.delay` is called, the task handler is used to create + the task. If `utils.delay` is called with `persistent=True`, the + task handler stores the new task and saves. + + It's easier to access these tasks (should it be necessary) using + `evennia.scripts.taskhandler.TASK_HANDLER`, which contains one + instance of this class, and use its `add` and `remove` methods. + + """ + + def __init__(self): + self.tasks = {} + self.to_save = {} + + def load(self): + """Load from the ServerConfig. + + Note: + This should be automatically called when Evennia starts. + It populates `self.tasks` according to the ServerConfig. + + """ + value = ServerConfig.objects.conf("delayed_tasks", default={}) + if isinstance(value, basestring): + tasks = dbunserialize(value) + else: + tasks = value + + # At this point, `tasks` contains a dictionary of still-serialized tasks + for task_id, value in tasks.items(): + date, callback, args, kwargs = dbunserialize(value) + if isinstance(callback, tuple): + # `callback` can be an object and name for instance methods + obj, method = callback + callback = getattr(obj, method) + self.tasks[task_id] = (date, callback, args, kwargs) + + def save(self): + """Save the tasks in ServerConfig.""" + for task_id, (date, callback, args, kwargs) in self.tasks.items(): + if task_id in self.to_save: + continue + + if getattr(callback, "__self__", None): + # `callback` is an instance method + obj = callback.__self__ + name = callback.__name__ + callback = (obj, name) + + # Check if callback can be pickled. args and kwargs have been checked + safe_callback = None + + try: + dbserialize(callback) + except (TypeError, AttributeError): + raise ValueError("the specified callback {} cannot be pickled. " \ + "It must be a top-level function in a module or an " \ + "instance method.".format(callback)) + else: + safe_callback = callback + + self.to_save[task_id] = dbserialize((date, safe_callback, args, kwargs)) + ServerConfig.objects.conf("delayed_tasks", self.to_save) + + def add(self, timedelay, callback, *args, **kwargs): + """Add a new persistent task in the configuration. + + Args: + timedelay (int or float): time in sedconds before calling the callback. + callback (function or instance method): the callback itself + any (any): any additional positional arguments to send to the callback + + Kwargs: + persistent (bool, optional): persist the task (store it). + any (any): additional keyword arguments to send to the callback + + """ + persistent = kwargs.get("persistent", False) + if persistent: + del kwargs["persistent"] + now = datetime.now() + delta = timedelta(seconds=timedelay) + + # Choose a free task_id + safe_args = [] + safe_kwargs = {} + used_ids = self.tasks.keys() + task_id = 1 + while task_id in used_ids: + task_id += 1 + + # Check that args and kwargs contain picklable information + for arg in args: + try: + dbserialize(arg) + except (TypeError, AttributeError): + logger.log_err("The positional argument {} cannot be " \ + "pickled and will not be present in the arguments " \ + "fed to the callback {}".format(arg, callback)) + else: + safe_args.append(arg) + + for key, value in kwargs.items(): + try: + dbserialize(value) + except (TypeError, AttributeError): + logger.log_err("The {} keyword argument {} cannot be " \ + "pickled and will not be present in the arguments " \ + "fed to the callback {}".format(key, value, callback)) + else: + safe_kwargs[key] = value + + self.tasks[task_id] = (now + delta, callback, safe_args, safe_kwargs) + self.save() + callback = self.do_task + args = [task_id] + kwargs = {} + + return task.deferLater(reactor, timedelay, callback, *args, **kwargs) + + def remove(self, task_id): + """Remove a persistent task without executing it. + + Args: + task_id (int): an existing task ID. + + Note: + A non-persistent task doesn't have a task_id, it is not stored + in the TaskHandler. + + """ + del self.tasks[task_id] + if task_id in self.to_save: + del self.to_save[task_id] + + self.save() + + def do_task(self, task_id): + """Execute the task (call its callback). + + Args: + task_id (int): a valid task ID. + + Note: + This will also remove it from the list of current tasks. + + """ + date, callback, args, kwargs = self.tasks.pop(task_id) + if task_id in self.to_save: + del self.to_save[task_id] + + self.save() + callback(*args, **kwargs) + + def create_delays(self): + """Create the delayed tasks for the persistent tasks. + + Note: + This method should be automatically called when Evennia starts. + + """ + now = datetime.now() + for task_id, (date, callbac, args, kwargs) in self.tasks.items(): + seconds = max(0, (date - now).total_seconds()) + task.deferLater(reactor, seconds, self.do_task, task_id) + + +# Create the soft singleton +TASK_HANDLER = TaskHandler() + diff --git a/evennia/server/server.py b/evennia/server/server.py index 038846e5b4..d6a47c208b 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -455,6 +455,11 @@ class Evennia(object): # (this also starts any that didn't yet start) ScriptDB.objects.validate(init_mode=mode) + # start the task handler + from evennia.scripts.taskhandler import TASK_HANDLER + TASK_HANDLER.load() + TASK_HANDLER.create_delays() + # delete the temporary setting ServerConfig.objects.conf("server_restart_mode", delete=True) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 05ff24f0ed..ab67421ad4 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -44,7 +44,6 @@ _DA = object.__delattr__ _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH - def is_iter(iterable): """ Checks if an object behaves iterably. @@ -920,6 +919,8 @@ def uses_database(name="sqlite3"): return engine == "django.db.backends.%s" % name +_TASK_HANDLER = None + def delay(timedelay, callback, *args, **kwargs): """ Delay the return of a value. @@ -930,7 +931,9 @@ def delay(timedelay, callback, *args, **kwargs): arguments after `timedelay` seconds. args (any, optional): Will be used as arguments to callback Kwargs: - any (any): Will be used to call the callback. + persistent (bool, optional): should make the delay persistent + over a reboot or reload + any (any): Will be used to call the callback. Returns: deferred (deferred): Will fire fire with callback after @@ -939,8 +942,21 @@ def delay(timedelay, callback, *args, **kwargs): defined directly in the command body and don't need to be specified here. + Note: + The task handler (`evennia.scripts.taskhandler.TASK_HANDLEr`) will + be called for persistent or non-persistent tasks. + If persistent is set to True, the callback, its arguments + and other keyword arguments will be saved in the database, + assuming they can be. The callback will be executed even after + a server restart/reload, taking into account the specified delay + (and server down time). + """ - return task.deferLater(reactor, timedelay, callback, *args, **kwargs) + global _TASK_HANDLER + # Do some imports here to avoid circular import and speed things up + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + return _TASK_HANDLER.add(timedelay, callback, *args, **kwargs) _TYPECLASSMODELS = None