diff --git a/evennia/server/server.py b/evennia/server/server.py index 038846e5b4..7c4bf46056 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 persistent tasks + from evennia.utils.persistent import PERSISTENT_TASKS + PERSISTENT_TASKS.load() + PERSISTENT_TASKS.create_delays() + # delete the temporary setting ServerConfig.objects.conf("server_restart_mode", delete=True) diff --git a/evennia/utils/persistent.py b/evennia/utils/persistent.py new file mode 100644 index 0000000000..b0cd3275fd --- /dev/null +++ b/evennia/utils/persistent.py @@ -0,0 +1,173 @@ +""" +Module containing persistent features and the persistent differed +tasks, generated by utils.delay(persistent=True). + +""" + +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 + +PERSISTENT_TASKS = None + +class PersistentTasks(object): + + """A light singleton wrapper allowing to access permanent tasks. + + Permament tasks are created by `utils.delay` when the `persistent` + keyword argument is set to True. Tasks are saved in ServerConfig. + It's easier to access these tasks (should it be necessary) using + `evennia.utils.persistent.PERSISTSENT_TASKS`, which contains one + instance of this class, and use its `add and `remove` methods. + + """ + + def __init__(self): + self.tasks = {} + + 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 unserialized dictionary of tasks + for task_id, (date, callback, args, kwargs) in tasks.items(): + 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.""" + to_save = {} + for task_id, (date, callback, args, kwargs) in self.tasks.items(): + 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 + + to_save[task_id] = (date, safe_callback, args, kwargs) + to_save = dbserialize(to_save) + ServerConfig.objects.conf("delayed_tasks", 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: + any (any): additional keyword arguments to send to the callback + + Note: + This doesn't create any delay at this time to call the task, + but the task is written in ServerConfig along with the + others. + + """ + 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() + return task_id + + def remove(self, task_id): + """Remove a task without executing it. + + Args: + task_id (int): an existing task ID. + + """ + del self.tasks[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) + 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 = (date - now).total_seconds() + if seconds < 0: + seconds = 0 + + task.deferLater(reactor, seconds, self.do_task, task_id) + + +# Create the soft singleton +PERSISTENT_TASKS = PersistentTasks() diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 05ff24f0ed..a7f47ca555 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 +_PERSISTENT_TASKS = None + def delay(timedelay, callback, *args, **kwargs): """ Delay the return of a value. @@ -930,6 +931,8 @@ def delay(timedelay, callback, *args, **kwargs): arguments after `timedelay` seconds. args (any, optional): Will be used as arguments to callback Kwargs: + persistent (bool, optional): should make the delay persistent + over a reboot or reload any (any): Will be used to call the callback. Returns: @@ -939,7 +942,26 @@ def delay(timedelay, callback, *args, **kwargs): defined directly in the command body and don't need to be specified here. + Note: + 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). + """ + global _PERSISTENT_TASKS + persistent = kwargs.get("persistent", False) + if persistent: + del kwargs["persistent"] + # Do some imports here to avoid circular import and speed things up + if _PERSISTENT_TASKS is None: + from evennia.utils.persistent import PERSISTENT_TASKS as _PERSISTENT_TASKS + task_id = _PERSISTENT_TASKS.add(timedelay, callback, *args, **kwargs) + callback = _PERSISTENT_TASKS.do_task + args = [task_id] + kwargs = {} + return task.deferLater(reactor, timedelay, callback, *args, **kwargs)