diff --git a/evennia/contrib/slow_exit.py b/evennia/contrib/slow_exit.py index 107d274b8c..7f7f2cc249 100644 --- a/evennia/contrib/slow_exit.py +++ b/evennia/contrib/slow_exit.py @@ -70,11 +70,11 @@ class SlowExit(DefaultExit): traversing_object.msg("You start moving %s at a %s." % (self.key, move_speed)) # create a delayed movement - deferred = utils.delay(move_delay, move_callback) + t = utils.delay(move_delay, move_callback) # we store the deferred on the character, this will allow us # to abort the movement. We must use an ndb here since # deferreds cannot be pickled. - traversing_object.ndb.currently_moving = deferred + traversing_object.ndb.currently_moving = t # diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py index 6f5dccc4f9..bd979a9fde 100644 --- a/evennia/scripts/taskhandler.py +++ b/evennia/scripts/taskhandler.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from twisted.internet import reactor from twisted.internet.task import deferLater +from twisted.internet.defer import CancelledError as DefCancelledError from evennia.server.models import ServerConfig from evennia.utils.logger import log_err from evennia.utils.dbserialize import dbserialize, dbunserialize @@ -13,31 +14,213 @@ from evennia.utils.dbserialize import dbserialize, dbunserialize TASK_HANDLER = None -class TaskHandler(object): +def handle_error(*args, **kwargs): + """Handle errors within deferred objects.""" + for arg in args: + # suppress cancel errors + if arg.type == DefCancelledError: + continue + raise arg + + +class TaskHandlerTask: + """An object to represent a single TaskHandler task. + + Instance Attributes: + task_id (int): the global id for this task + deferred (deferred): a reference to this task's deferred + Property Attributes: + paused (bool): check if the deferred instance of a task has been paused. + called(self): A task attribute to check if the deferred instance of a task has been called. + + Methods: + pause(): Pause the callback of a task. + unpause(): Process all callbacks made since pause() was called. + do_task(): Execute the task (call its callback). + call(): Call the callback of this task. + remove(): Remove a task without executing it. + cancel(): Stop a task from automatically executing. + active(): Check if a task is active (has not been called yet). + exists(): Check if a task exists. + get_id(): Returns the global id for this task. For use with """ - A light singleton wrapper allowing to access permanent tasks. + + def __init__(self, task_id): + self.task_id = task_id + self.deferred = TASK_HANDLER.get_deferred(task_id) + + def get_deferred(self): + """Return the instance of the deferred the task id is using. + + Returns: + bool or deferred: An instance of a deferred or False if there is no task with the id. + None is returned if there is no deferred affiliated with this id. + + """ + return TASK_HANDLER.get_deferred(self.task_id) + + def pause(self): + """Pause the callback of a task. + To resume use TaskHandlerTask.unpause + """ + d = self.deferred + if d: + d.pause() + + def unpause(self): + """Unpause a task, run the task if it has passed delay time.""" + d = self.deferred + if d: + d.unpause() + + @property + def paused(self): + """A task attribute to check if the deferred instance of a task has been paused. + + This exists to mock usage of a twisted deferred object. + + Returns: + bool or None: True if the task was properly paused. None if the task does not have + a deferred instance. + + """ + d = self.deferred + if d: + return d.paused + else: + return None + + def do_task(self): + """Execute the task (call its callback). + If calling before timedelay, cancel the deferred instance affliated to this task. + Remove the task from the dictionary of current tasks on a successful + callback. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + return TASK_HANDLER.do_task(self.task_id) + + def call(self): + """Call the callback of a task. + Leave the task unaffected otherwise. + This does not use the task's deferred instance. + The only requirement is that the task exist in task handler. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + return TASK_HANDLER.call_task(self.task_id) + + def remove(self): + """Remove a task without executing it. + Deletes the instance of the task's deferred. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the removal completed successfully. + + """ + return TASK_HANDLER.remove(self.task_id) + + def cancel(self): + """Stop a task from automatically executing. + This will not remove the task. + + Returns: + bool: True if the cancel completed successfully. + False if the cancel did not complete successfully. + + """ + return TASK_HANDLER.cancel(self.task_id) + + def active(self): + """Check if a task is active (has not been called yet). + + Returns: + bool: True if a task is active (has not been called yet). False if + it is not (has been called) or if the task does not exist. + + """ + return TASK_HANDLER.active(self.task_id) + + @property + def called(self): + """ + A task attribute to check if the deferred instance of a task has been called. + + This exists to mock usage of a twisted deferred object. + It will not set to True if Task.call has been called. This only happens if + task's deferred instance calls the callback. + + Returns: + bool: True if the deferred instance of this task has called the callback. + False if the deferred instnace of this task has not called the callback. + + """ + d = self.deferred + if d: + return d.called + else: + return None + + def exists(self): + """Check if a task exists. + Most task handler methods check for existence for you. + + Returns: + bool: True the task exists False if it does not. + + """ + return TASK_HANDLER.exists(self.task_id) + + def get_id(self): + """ Returns the global id for this task. For use with + `evennia.scripts.taskhandler.TASK_HANDLER`. + + Returns: + task_id (int): global task id for this task. + + """ + return self.task_id + + +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. + the task. - 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. + Task handler will automatically remove uncalled but canceled from task + handler. By default this will not occur until a canceled task + has been uncalled for 60 second after the time it should have been called. + To adjust this time use TASK_HANDLER.stale_timeout. If stale_timeout is 0 + stale tasks will not be automatically removed. + This is not done on a timer. I is done as new tasks are added or the load method is called. """ def __init__(self): self.tasks = {} self.to_save = {} + self.clock = reactor + # number of seconds before an uncalled canceled task is removed from TaskHandler + self.stale_timeout = 60 + self._now = False # used in unit testing to manually set now time def load(self): """Load from the ServerConfig. - Note: - This should be automatically called when Evennia starts. - It populates `self.tasks` according to the ServerConfig. + This should be automatically called when Evennia starts. + It populates `self.tasks` according to the ServerConfig. """ to_save = False @@ -58,16 +241,42 @@ class TaskHandler(object): continue callback = getattr(obj, method) - self.tasks[task_id] = (date, callback, args, kwargs) + self.tasks[task_id] = (date, callback, args, kwargs, True, None) + if self.stale_timeout > 0: # cleanup stale tasks. + self.clean_stale_tasks() if to_save: self.save() + def clean_stale_tasks(self): + """remove uncalled but canceled from task handler. + + By default this will not occur until a canceled task + has been uncalled for 60 second after the time it should have been called. + To adjust this time use TASK_HANDLER.stale_timeout. + + """ + clean_ids = [] + for task_id, (date, callback, args, kwargs, persistent, _) in self.tasks.items(): + if not self.active(task_id): + stale_date = date + timedelta(seconds=self.stale_timeout) + # if a now time is provided use it (intended for unit testing) + now = self._now if self._now else datetime.now() + # the task was canceled more than stale_timeout seconds ago + if now > stale_date: + clean_ids.append(task_id) + for task_id in clean_ids: + self.remove(task_id) + return True + def save(self): """Save the tasks in ServerConfig.""" - for task_id, (date, callback, args, kwargs) in self.tasks.items(): + + for task_id, (date, callback, args, kwargs, persistent, _) in self.tasks.items(): if task_id in self.to_save: continue + if not persistent: + continue if getattr(callback, "__self__", None): # `callback` is an instance method @@ -93,31 +302,49 @@ class TaskHandler(object): ServerConfig.objects.conf("delayed_tasks", self.to_save) def add(self, timedelay, callback, *args, **kwargs): - """Add a new persistent task in the configuration. + """Add a new task. + + If the persistent kwarg is truthy: + The callback, args and values for kwarg will be serialized. Type + and attribute errors during the serialization will be logged, + but will not throw exceptions. + For persistent tasks do not use memory references in the callback + function or arguments. After a restart those memory references are no + longer accurate. Args: - timedelay (int or float): time in sedconds before calling the callback. + timedelay (int or float): time in seconds before calling the callback. callback (function or instance method): the callback itself any (any): any additional positional arguments to send to the callback + *args: positional arguments to pass to callback. + **kwargs: keyword arguments to pass to callback. + persistent (bool, optional): persist the task (stores it). + persistent key and value is removed from kwargs it will + not be passed to callback. - Keyword Args: - persistent (bool, optional): persist the task (store it). - any (any): additional keyword arguments to send to the callback + Returns: + TaskHandlerTask: An object to represent a task. + Reference evennia.scripts.taskhandler.TaskHandlerTask for complete details. """ - persistent = kwargs.get("persistent", False) - if persistent: - del kwargs["persistent"] - now = datetime.now() - delta = timedelta(seconds=timedelay) + # set the completion time + # Only used on persistent tasks after a restart + now = datetime.now() + delta = timedelta(seconds=timedelay) + comp_time = now + delta + # get an open task id + used_ids = list(self.tasks.keys()) + task_id = 1 + while task_id in used_ids: + task_id += 1 - # Choose a free task_id + # record the task to the tasks dictionary + persistent = kwargs.get("persistent", False) + if "persistent" in kwargs: + del kwargs["persistent"] + if persistent: safe_args = [] safe_kwargs = {} - used_ids = list(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: @@ -144,59 +371,219 @@ class TaskHandler(object): else: safe_kwargs[key] = value - self.tasks[task_id] = (now + delta, callback, safe_args, safe_kwargs) + self.tasks[task_id] = (comp_time, callback, safe_args, safe_kwargs, persistent, None) self.save() - callback = self.do_task - args = [task_id] - kwargs = {} + else: # this is a non-persitent task + self.tasks[task_id] = (comp_time, callback, args, kwargs, persistent, None) - return deferLater(reactor, timedelay, callback, *args, **kwargs) + # defer the task + callback = self.do_task + args = [task_id] + kwargs = {} + d = deferLater(self.clock, timedelay, callback, *args, **kwargs) + d.addErrback(handle_error) - def remove(self, task_id): - """Remove a persistent task without executing it. + # some tasks may complete before the deferred can be added + if task_id in self.tasks: + task = self.tasks.get(task_id) + task = list(task) + task[4] = persistent + task[5] = d + self.tasks[task_id] = task + else: # the task already completed + return False + if self.stale_timeout > 0: + self.clean_stale_tasks() + return TaskHandlerTask(task_id) + + def exists(self, task_id): + """Check if a task exists. + Most task handler methods check for existence for you. 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. + Returns: + bool: True the task exists False if it does not. """ - del self.tasks[task_id] + if task_id in self.tasks: + return True + else: + return False + + def active(self, task_id): + """Check if a task is active (has not been called yet). + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if a task is active (has not been called yet). False if + it is not (has been called) or if the task does not exist. + + """ + if task_id in self.tasks: + # if the task has not been run, cancel it + deferred = self.get_deferred(task_id) + return not (deferred and deferred.called) + else: + return False + + def cancel(self, task_id): + """Stop a task from automatically executing. + This will not remove the task. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the cancel completed successfully. + False if the cancel did not complete successfully. + + """ + if task_id in self.tasks: + # if the task has not been run, cancel it + d = self.get_deferred(task_id) + if d: # it is remotely possible for a task to not have a deferred + if d.called: + return False + else: # the callback has not been called yet. + d.cancel() + return True + else: # this task has no deferred instance + return False + else: + return False + + def remove(self, task_id): + """Remove a task without executing it. + Deletes the instance of the task's deferred. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the removal completed successfully. + + """ + d = None + # delete the task from the tasks dictionary + if task_id in self.tasks: + # if the task has not been run, cancel it + self.cancel(task_id) + del self.tasks[task_id] # delete the task from the tasks dictionary + # remove the task from the persistent dictionary and ServerConfig if task_id in self.to_save: del self.to_save[task_id] + self.save() # remove from ServerConfig.objects + # delete the instance of the deferred + if d: + del d + return True - self.save() + def clear(self, save=True, cancel=True): + """clear all tasks. + By default tasks are canceled and removed from the database also. + + Args: + save=True (bool): Should changes to persistent tasks be saved to database. + cancel=True (bool): Cancel scheduled tasks before removing it from task handler. + + Returns: + True (bool): if the removal completed successfully. + + """ + if self.tasks: + for task_id in self.tasks.keys(): + if cancel: + self.cancel(task_id) + self.tasks = {} + if self.to_save: + self.to_save = {} + if save: + self.save() + return True + + def call_task(self, task_id): + """Call the callback of a task. + Leave the task unaffected otherwise. + This does not use the task's deferred instance. + The only requirement is that the task exist in task handler. + + Args: + task_id (int): an existing task ID. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + if task_id in self.tasks: + date, callback, args, kwargs, persistent, d = self.tasks.get(task_id) + else: # the task does not exist + return False + return callback(*args, **kwargs) def do_task(self, task_id): """Execute the task (call its callback). + If calling before timedelay cancel the deferred instance affliated to this task. + Remove the task from the dictionary of current tasks on a successful + callback. Args: task_id (int): a valid task ID. - Note: - This will also remove it from the list of current tasks. + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. """ - date, callback, args, kwargs = self.tasks.pop(task_id) - if task_id in self.to_save: - del self.to_save[task_id] + callback_return = False + if task_id in self.tasks: + date, callback, args, kwargs, persistent, d = self.tasks.get(task_id) + else: # the task does not exist + return False + if d: # it is remotely possible for a task to not have a deferred + if not d.called: # the task's deferred has not been called yet + d.cancel() # cancel the automated callback + else: # this task has no deferred, and should not be called + return False + callback_return = callback(*args, **kwargs) + self.remove(task_id) + return callback_return - self.save() - callback(*args, **kwargs) + def get_deferred(self, task_id): + """ + Return the instance of the deferred the task id is using. + + Args: + task_id (int): a valid task ID. + + Returns: + bool or deferred: An instance of a deferred or False if there is no task with the id. + None is returned if there is no deferred affiliated with this id. + + """ + if task_id in self.tasks: + return self.tasks[task_id][5] + else: + return None def create_delays(self): """Create the delayed tasks for the persistent tasks. - - Note: - This method should be automatically called when Evennia starts. + This method should be automatically called when Evennia starts. """ now = datetime.now() - for task_id, (date, callbac, args, kwargs) in self.tasks.items(): + for task_id, (date, callback, args, kwargs, _, _) in self.tasks.items(): + self.tasks[task_id] = date, callback, args, kwargs, True, None seconds = max(0, (date - now).total_seconds()) - deferLater(reactor, seconds, self.do_task, task_id) + d = deferLater(self.clock, seconds, self.do_task, task_id) + d.addErrback(handle_error) + # some tasks may complete before the deferred can be added + if self.tasks.get(task_id, False): + self.tasks[task_id] = date, callback, args, kwargs, True, d # Create the soft singleton diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 67625bbfbb..61c3ac0d33 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -6,13 +6,16 @@ TODO: Not nearly all utilities are covered yet. """ import os.path - import mock +from datetime import datetime, timedelta + from django.test import TestCase from datetime import datetime +from twisted.internet import task from evennia.utils.ansi import ANSIString from evennia.utils import utils +from evennia.utils.test_resources import EvenniaTest class TestIsIter(TestCase): @@ -292,3 +295,241 @@ class LatinifyTest(TestCase): byte_str = utils.to_bytes(self.example_str) result = utils.latinify(byte_str) self.assertEqual(result, self.expected_output) + + +_TASK_HANDLER = None + + +def dummy_func(obj): + """ + Used in TestDelay. + + A function that: + can be serialized + uses no memory references + uses evennia objects + """ + # get a reference of object + from evennia.objects.models import ObjectDB + obj = ObjectDB.objects.object_search(obj) + obj = obj[0] + # make changes to object + obj.ndb.dummy_var = 'dummy_func ran' + return True + + +class TestDelay(EvenniaTest): + """ + Test utils.delay. + """ + + def setUp(self): + super().setUp() + # get a reference of TASK_HANDLER + self.timedelay = 5 + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + _TASK_HANDLER.clock = task.Clock() + self.char1.ndb.dummy_var = False + + def tearDown(self): + super().tearDown() + _TASK_HANDLER.clear() + + def test_call_early(self): + # call a task early with call + for pers in (True, False): + t = utils.delay(self.timedelay, dummy_func, self.char1.dbref, persistent=pers) + result = t.call() + self.assertTrue(result) + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') + self.assertTrue(t.exists()) + self.assertTrue(t.active()) + self.char1.ndb.dummy_var = False + + def test_do_task(self): + # call the task early with do_task + for pers in (True, False): + t = utils.delay(self.timedelay, dummy_func, self.char1.dbref, persistent=pers) + # call the task early to test Task.call and TaskHandler.call_task + result = t.do_task() + self.assertTrue(result) + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') + self.assertFalse(t.exists()) + self.char1.ndb.dummy_var = False + + def test_deferred_call(self): + # wait for deferred to call + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') + self.assertFalse(t.exists()) + self.char1.ndb.dummy_var = False + + def test_short_deferred_call(self): + # wait for deferred to call with a very short time + timedelay = .1 + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') + self.assertFalse(t.exists()) + self.char1.ndb.dummy_var = False + + def test_active(self): + timedelay = self.timedelay + t = utils.delay(timedelay, dummy_func, self.char1.dbref) + self.assertTrue(_TASK_HANDLER.active(t.get_id())) + self.assertTrue(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertFalse(_TASK_HANDLER.active(t.get_id())) + self.assertFalse(t.active()) + + def test_called(self): + timedelay = self.timedelay + t = utils.delay(timedelay, dummy_func, self.char1.dbref) + self.assertFalse(t.called) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertTrue(t.called) + + def test_cancel(self): + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + success = t.cancel() + self.assertFalse(t.active()) + self.assertTrue(success) + self.assertTrue(t.exists()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, False) + + def test_remove(self): + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + success = t.remove() + self.assertTrue(success) + self.assertFalse(t.active()) + self.assertFalse(t.exists()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, False) + + def test_remove_canceled(self): + # remove a canceled task + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + success = t.cancel() + self.assertTrue(success) + self.assertTrue(t.exists()) + self.assertFalse(t.active()) + success = t.remove() + self.assertTrue(success) + self.assertFalse(t.exists()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, False) + + def test_pause_unpause(self): + # remove a canceled task + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + self.assertTrue(t.active()) + t.pause() + self.assertTrue(t.paused) + t.unpause() + self.assertFalse(t.paused) + self.assertEqual(self.char1.ndb.dummy_var, False) + t.pause() + _TASK_HANDLER.clock.advance(timedelay) # make time pass + self.assertEqual(self.char1.ndb.dummy_var, False) + t.unpause() + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') + self.char1.ndb.dummy_var = False + + def test_auto_stale_task_removal(self): + # automated removal of stale tasks. + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + t.cancel() + self.assertFalse(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + if pers: + self.assertTrue(t.get_id() in _TASK_HANDLER.to_save) + self.assertTrue(t.get_id() in _TASK_HANDLER.tasks) + # Make task handler's now time, after the stale timeout + _TASK_HANDLER._now = datetime.now() + timedelta(seconds=_TASK_HANDLER.stale_timeout + timedelay + 1) + # add a task to test automatic removal + t2 = utils.delay(timedelay, dummy_func, self.char1.dbref) + if pers: + self.assertFalse(t.get_id() in _TASK_HANDLER.to_save) + self.assertFalse(t.get_id() in _TASK_HANDLER.tasks) + self.assertEqual(self.char1.ndb.dummy_var, False) + _TASK_HANDLER.clear() + + def test_manual_stale_task_removal(self): + # manual removal of stale tasks. + timedelay = self.timedelay + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + t.cancel() + self.assertFalse(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + if pers: + self.assertTrue(t.get_id() in _TASK_HANDLER.to_save) + self.assertTrue(t.get_id() in _TASK_HANDLER.tasks) + # Make task handler's now time, after the stale timeout + _TASK_HANDLER._now = datetime.now() + timedelta(seconds=_TASK_HANDLER.stale_timeout + timedelay + 1) + _TASK_HANDLER.clean_stale_tasks() # cleanup of stale tasks in in the save method + if pers: + self.assertFalse(t.get_id() in _TASK_HANDLER.to_save) + self.assertFalse(t.get_id() in _TASK_HANDLER.tasks) + self.assertEqual(self.char1.ndb.dummy_var, False) + _TASK_HANDLER.clear() + + def test_disable_stale_removal(self): + # manual removal of stale tasks. + timedelay = self.timedelay + _TASK_HANDLER.stale_timeout = 0 + for pers in (False, True): + t = utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=pers) + t.cancel() + self.assertFalse(t.active()) + _TASK_HANDLER.clock.advance(timedelay) # make time pass + if pers: + self.assertTrue(t.get_id() in _TASK_HANDLER.to_save) + self.assertTrue(t.get_id() in _TASK_HANDLER.tasks) + # Make task handler's now time, after the stale timeout + _TASK_HANDLER._now = datetime.now() + timedelta(seconds=_TASK_HANDLER.stale_timeout + timedelay + 1) + t2 = utils.delay(timedelay, dummy_func, self.char1.dbref) + if pers: + self.assertTrue(t.get_id() in _TASK_HANDLER.to_save) + self.assertTrue(t.get_id() in _TASK_HANDLER.tasks) + self.assertEqual(self.char1.ndb.dummy_var, False) + # manual removal should still work + _TASK_HANDLER.clean_stale_tasks() # cleanup of stale tasks in in the save method + if pers: + self.assertFalse(t.get_id() in _TASK_HANDLER.to_save) + self.assertFalse(t.get_id() in _TASK_HANDLER.tasks) + _TASK_HANDLER.clear() + + def test_server_restart(self): + # emulate a server restart + timedelay = self.timedelay + utils.delay(timedelay, dummy_func, self.char1.dbref, persistent=True) + _TASK_HANDLER.clear(False) # remove all tasks from task handler, do not save this change. + _TASK_HANDLER.clock.advance(timedelay) # advance twisted reactor time past callback time + self.assertEqual(self.char1.ndb.dummy_var, False) # task has not run + _TASK_HANDLER.load() # load persistent tasks from database. + _TASK_HANDLER.create_delays() # create new deffered instances from persistent tasks + _TASK_HANDLER.clock.advance(timedelay) # Clock must advance to trigger, even if past timedelay + self.assertEqual(self.char1.ndb.dummy_var, 'dummy_func ran') diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index b0abe2b9c7..105f9474a6 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1022,7 +1022,7 @@ _TASK_HANDLER = None def delay(timedelay, callback, *args, **kwargs): """ - Delay the return of a value. + Delay the calling of a callback (function). Args: timedelay (int or float): The delay in seconds @@ -1040,15 +1040,26 @@ def delay(timedelay, callback, *args, **kwargs): commandhandler callback chain, the callback chain can be defined directly in the command body and don't need to be specified here. + Reference twisted.internet.defer.Deferred + if persistent kwarg is truthy: + task_id (int): the task's id intended for use with + evennia.scripts.taskhandler.TASK_HANDLER's do_task and remove methods. 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, + and other keyword arguments will be saved (serialized) 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). + Keep in mind that persistent tasks arguments and callback should not + use memory references. + If persistent is set to True the delay function will return an int + which is the task's id itended for use with TASK_HANDLER's do_task + and remove methods. + + All task's whose time delays have passed will be called on server startup. """ global _TASK_HANDLER