frappy/frappy/lib/statemachine.py
Markus Zolliker a14c282993 redesign of the state machine
With the current implementation, we run into a deadlock with the lock
from the state machine interfering with the accessLock on the module.
We can not wait for the state machine to finish while having the
accessLock locked by write_target. As a consequence, when restarting
the state machine we should not wait, but remember the state function
to call and postpone the restart after the cleanup has finished.
For this, we want to know the status before calling the state function.

- create HasState mixin, using doPoll for driving the machine
- StatusCode decorator for assigning a status to a state function
- remove the state machines 'threaded' option
- 'Retry' is now a unique value instead of a class. The retry period
  is determined by the (fast) poll interval.
- return 'Finish' instead of None for finishing the machine. returning
  None for state function is now an error, as this might happen
  easily inadvertently.

Change-Id: Icb31367442f10e98be69af3e05a84f12ce5cc966
2022-12-06 10:18:50 +01:00

248 lines
8.9 KiB
Python

# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""a simple, but powerful state machine
Mechanism
---------
The code for the state machine is NOT to be implemented as a subclass
of StateMachine, but usually as functions or methods of an other object.
The created state object may hold variables needed for the state.
A state function may return either:
- a function for the next state to transition to
- Retry to keep the state and call the state function again
- or Finish for finishing
Initialisation Code
-------------------
For code to be called only after a state transition, use statemachine.init.
def state_x(statemachine):
if statemachine.init:
... code to be execute only after entering state x ...
... further code ...
Restart
-------
To restart the statemachine, call statemachine.start. The current task is interrupted,
the cleanup sequence is called, and after this the machine is restarted with the
arguments of the start method.
Stop
----
To stop the statemachine, call statemachine.stop. The current task is interrupted,
the cleanup sequence is called, and the machine finishes.
Cleaning Up
-----------
A cleanup function might be added as arguments to StateMachine.start.
On error, stop or restart, the cleanup sequence will be executed.
The cleanup itself is not be interrupted:
- if a further exeception is raised, the machine is interrupted immediately
- if start or stop is called again, a previous start or stop is ignored.
The currently running cleanup sequence is finished, and not started again.
"""
import time
import threading
from logging import getLogger
from frappy.lib import UniqueObject
Retry = UniqueObject('Retry')
Finish = UniqueObject('Finish')
class Start:
def __init__(self, newstate, kwds):
self.newstate = newstate
self.kwds = kwds # statemachine attributes
class Stop:
pass
class StateMachine:
"""a simple, but powerful state machine"""
# class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start`
statefunc = None # the current statefunc
now = None # the current time (avoid mutiple calls within a state)
init = True # True only in the first call of a state after a transition
next_task = None # None or an instance of Start or Stop
cleanup_reason = None # None or an instance of Exception, Start or Stop
_last_time = 0 # for delta method
def __init__(self, statefunc=None, logger=None, **kwds):
"""initialize state machine
:param statefunc: if given, this is the first statefunc
:param logger: an optional logger
:param kwds: any attributes for the state object
"""
self.cleanup = None
self.transition = None
self.maxloops = 10 # the maximum number of statefunc functions called in sequence without Retry
self.now = time.time() # avoids calling time.time several times per statefunc
self.log = logger or getLogger('dummy')
self._lock = threading.Lock()
self._update_attributes(kwds)
if statefunc:
self.start(statefunc)
def _update_attributes(self, kwds):
"""update allowed attributes"""
cls = type(self)
for key, value in kwds.items():
if hasattr(cls, key):
raise AttributeError('can not set %s.%s' % (cls.__name__, key))
setattr(self, key, value)
def _cleanup(self, reason):
if isinstance(reason, Exception):
self.log.warning('%s: raised %r', self.statefunc.__name__, reason)
elif isinstance(reason, Stop):
self.log.debug('stopped in %s', self.statefunc.__name__)
else: # must be Start
self.log.debug('restart %s during %s', reason.newstate.__name__, self.statefunc.__name__)
if self.cleanup_reason is None:
self.cleanup_reason = reason
if not self.cleanup:
return None # no cleanup needed or cleanup already handled
with self._lock:
cleanup, self.cleanup = self.cleanup, None
ret = None
try:
ret = cleanup(self) # pylint: disable=not-callable # None or function
if not (ret is None or callable(ret)):
self.log.error('%s: return value must be callable or None, not %r',
self.statefunc.__name__, ret)
ret = None
except Exception as e:
self.log.exception('%r raised in cleanup', e)
return ret
@property
def is_active(self):
return bool(self.statefunc)
def _new_state(self, statefunc):
if self.transition:
self.transition(self, statefunc) # pylint: disable=not-callable # None or function
self.init = True
self.statefunc = statefunc
self._last_time = self.now
def cycle(self):
"""do one cycle
call state functions until Retry is returned
"""
for _ in range(2):
if self.statefunc:
for _ in range(self.maxloops):
self.now = time.time()
if self.next_task and not self.cleanup_reason:
# interrupt only when not cleaning up
ret = self._cleanup(self.next_task)
else:
try:
ret = self.statefunc(self)
self.init = False
if ret is Retry:
return
if ret is Finish:
break
if not callable(ret):
ret = self._cleanup(RuntimeError(
'%s: return value must be callable, Retry or Finish, not %r'
% (self.statefunc.__name__, ret)))
except Exception as e:
ret = self._cleanup(e)
if ret is None:
break
self._new_state(ret)
else:
ret = self._cleanup(RuntimeError(
'%s: too many states chained - probably infinite loop' % self.statefunc.__name__))
if ret:
self._new_state(ret)
continue
if self.cleanup_reason is None:
self.log.debug('finish in state %r', self.statefunc.__name__)
self._new_state(None)
if self.next_task:
with self._lock:
action, self.next_task = self.next_task, None
self.cleanup_reason = None
if isinstance(action, Start):
self._new_state(action.newstate)
self._update_attributes(action.kwds)
def start(self, statefunc, **kwds):
"""start with a new state
:param statefunc: the first state
:param kwds: items to put as attributes on the state machine
"""
kwds.setdefault('cleanup', None) # cleanup must be given on each restart
with self._lock:
self.next_task = Start(statefunc, kwds)
def stop(self):
"""stop machine, go to idle state"""
with self._lock:
self.next_task = Stop()
def delta(self, mindelta=0):
"""helper method for time dependent control
:param mindelta: minimum time since last call
:return: time delta or None when less than min delta time has passed
to be called from within an state function
Usage:
def state_x(self, state):
delta = state.delta(5)
if delta is None:
return # less than 5 seconds have passed, we wait for the next cycle
# delta is >= 5, and the zero time for delta is set
# now we can use delta for control calculations
remark: in the first step after start, state.delta(0) returns nearly 0
"""
delta = self.now - self._last_time
if delta < mindelta:
return None
self._last_time = self.now
return delta