-
Notifications
You must be signed in to change notification settings - Fork 537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
django and transitions [previously about model extensions] #146
Comments
default/fallback transitions (see #134) |
sqlalchemy support (see #141) |
Coroutine/asyncio Support (see #181) |
I don't think django integration is necessary as separate extension. There is a convenient way for attaching state machines to django models without any boilerplate. Maybe explicit example in the readme will be sophisticated. from django.db import models
from django.db.models.signals import post_init
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from transitions import Machine
class ModelWithState(models.Model):
ASLEEP = 'asleep'
HANGING_OUT = 'hanging out'
HUNGRY = 'hungry'
SWEATY = 'sweaty'
SAVING_THE_WORLD = 'saving the world'
STATE_TYPES = [
(ASLEEP, _('asleep')),
(HANGING_OUT, _('hanging out')),
(HUNGRY, _('hungry')),
(SWEATY, _('sweaty')),
(SAVING_THE_WORLD, _('saving the world')),
]
state = models.CharField(
_('state'),
max_length=100,
choices=STATE_TYPES,
default=ASLEEP,
help_text=_('actual state'),
)
@receiver(post_init, sender=ModelWithState)
def init_state_machine(instance, **kwargs):
states = [state for state, _ in instance.STATE_TYPES]
machine = instance.machine = Machine(model=instance, states=states, initial=instance.state)
machine.add_transition('work_out', instance.HANGING_OUT, instance.HUNGRY)
machine.add_transition('eat', instance.HUNGRY, instance.HANGING_OUT) |
@proofit404: Thanks for the remark. We will definitely add this to the documentation. |
Has anyone used this in project, I am fixing a memory leak problem after attaching a state Machine to the django object. The problem: Code: class ItemMachineMixin(object):
def __init__(self, *args, **kwargs):
super(ItemMachineMixin, self).__init__(*args, **kwargs)
self.machine = transitions.Machine(
model=self,
states=SM_STATES,
initial=self.status,
transitions=SM_TRANSITIONS,
auto_transitions=False,
send_event=True,
) I'm not sure if the problem is related to the state Machine, or am I missing something? Any advise is welcome, thank you. |
Does this memory usage start immediately at 800m or does it takes some time? |
@proofit404 I'm debugging this issue, by disabling the state machine, in a clean started django (with nginx/gunicorn), processing a django admin request increase the process memory from ~100M to ~250M and stop there, but with state machine enabled, also clean started django with same request, the memory grow up from ~100M to about 4GB (8GB * 48%). |
Looks strange, but I have no idea how to inspect where the memory goes, is there any instruction to profile django or python's memory problems? |
I added the example from proofit404 as the first answer to the faq notebook. |
@aleneum The "memory leak" issue I mentioned above, as I dive into it, I found it may be not a leak, but an expected behavior. In my situation, there are 7 states, 10 transitions and 4 conditions, according to the way pytransitions working, when a django model object is initialized, there will be more than (7 + 10 + 4 = 21) Machine/State/Transition/Condition objects being initialized, django has cache mechanism for model object for performance, so the huge amount objects will stay in memory, and then the memory blows. I suggest don't use this SM for models which have many records, especially for admin usage. Maybe we should mention this someway in the FAQ. Also, as a workaround, a used a custom version of "fysom" library to handle the model states, I changed the state machine to be a singleton class, by passing model object as parameter to transitions and conditions. Finally, I'm very appreciate this awesome project and like to use it in some other situations, thanks. |
@jxskiss Thanks for your work! If you can provide your solution as example filled with comments, it would be really helpful! I think this topic worth its own page in the documentation. |
@jxskiss: Thanks for the update. I am not completely familiar with how django handles its data. But is it really necessary? To attach a machine to every entry/record I mean. The cool thing about Edit: If you could distill a minimal example (project) of your current approach with transitions (does not have to reach 800M memory usage of course), I could check if I am on the right track here. |
@aleneum Yes, a model object is a data entry. A global machine is the solution I used finally, but I didn't find an easy setup with As proofit404 mentioned above, I initialized a machine instance (with states, transitions and conditions objects) for each model object. The problem is right here. Maybe I used Sample code with memory issue: class ItemStatus(object):
NEW = 'new'
NEED_INFO = 'need_info'
REVIEWING = 'reviewing'
REDOING = 'redoing'
CONFLICT = 'conflict'
VERIFIED = 'verified'
DELETED = 'deleted'
SM_STATES = [
NEW, NEED_INFO, REVIEWING, REDOING, CONFLICT, VERIFIED, DELETED
]
SM_TRANSITIONS = [
# trigger, source, destination
['sm_prepare_new', NEW, NEED_INFO],
{
'trigger': 'sm_commit_review',
'source': NEED_INFO,
'dest': REVIEWING,
'conditions': ['check_review_ready'],
},
{
'trigger': 'sm_done_verified',
'source': [REVIEWING, REDOING],
'dest': VERIFIED,
'conditions': ['check_required_fields', 'check_barcodes_valid', 'check_no_conflict'],
},
['sm_mark_conflict', [REVIEWING, REDOING], CONFLICT],
['sm_revert_verified', [VERIFIED, CONFLICT], REDOING],
['sm_require_info', [REVIEWING, REDOING], NEED_INFO],
{
'trigger': 'sm_mark_deleted',
'source': [
NEW, NEED_INFO, REVIEWING, REDOING, CONFLICT, VERIFIED
],
'dest': DELETED
},
['sm_revert_deleted', DELETED, REDOING],
{
'trigger': 'sm_update',
'source': [NEW, NEED_INFO, REVIEWING, REDOING, VERIFIED],
'dest': '=',
}
]
class ItemMachineMixin(object):
def __init__(self, *args, **kwargs):
super(ItemMachineMixin, self).__init__(*args, **kwargs)
self.machine = transitions.Machine(
model=self,
states=ItemStatus.SM_STATES,
initial=self.status,
transitions=ItemStatus.SM_TRANSITIONS,
auto_transitions=False,
send_event=True,
)
class Item(ItemMachineMixin, models.Model):
status = models.CharField(max_length=16, default='new')
# many other fields |
@proofit404 Thanks, for someone interested with my solution, I have posted it here: https://gist.github.com/jxskiss/01816eec9a2b64bae341f4d07f58646e |
Hi @jxskiss and @proofit404, I just finished some test runs and wanted to share the results. The full code can be found here and is based on what jxskiss provided. This is the most important stuff: # As a model, I used a simple one field model. `Item` was extended with a *MixIn*
class Item(<MIXIN>, models.Model):
state = models.CharField(max_length=16, default='new')
# This is a Singleton meta class from
# https://stackoverflow.com/questions/6760685/creating-a-singleton-in-python
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class GlobalMachine(transitions.Machine):
__metaclass__ = Singleton
# # In case you want to use set instead of list to store models
# def __init__(self, *args, **kwargs):
# super(GlobalMachine, self).__init__(*args, **kwargs)
# self.models = SetWrapper()
# Since transitions expects a list, extend set by a method 'append'
# You just need that in case you want to use set instead of list for storing models in Machine
class SetWrapper(set):
def append(self, item):
self.add(item)
# instead of adding a machine to each entry, we use a global machine here
# the constructor is only called ONCE and ignored after the global
# instance has been created.
# Note that we do not add a model or set an initial state;
# this will be done for each model entry instead
class ItemSingletonMachineMixin(object):
def __init__(self, *args, **kwargs):
super(ItemSingletonMachineMixin, self).__init__(*args, **kwargs)
machine = GlobalMachine(
model=None,
states=ItemStatus.SM_STATES,
transitions=ItemStatus.SM_TRANSITIONS,
initial=None,
auto_transitions=False,
send_event=True
)
# save item to be hashable
self.save()
machine.add_model(self, initial=self.state) EvaluationI tested 5 kinds of models with different MixIns:
note that I wrote "add a customized fysom machine" since imho ProcessI wrote some helper functions to create instances of ResultsWith a memory limit of 250MB I could create:
DiscussionAs already mentioned by jxskiss, adding a Note that the tested model was close to the smallest possible model. In case your entry includes more fields and complex datatypes, the impact of a model decoration with a state machine will be reduced. These results may vary depending on when and how Python does the garbage collection and/or rearranges stuff in memory. Using
Solution 1: Skip checkA subclass of Solution 2: Use
|
@aleneum Very impressive comparison! There are two points I want to mention: First, when using And, as I posted in the gist, I used the Machine as a class attribute, so there will be only one simple Machine object globally without registry, the machine is totally stateless, any object related thing is passed to machine methods as the
Thanks for your instruction about how to use |
Also as bonus of attaching StateMachine as a class attribute, one can use different state machine for different Django Models within one application, since it's not a really singleton. |
You can use
Ah! Thats what I overlooked. Thanks for the clarification. Sounds very reasonable. I will update the previous report accordingly. |
@jxskiss, hello once again. just for the sake of completeness: The approach you have chosen (machine as class attribute; pass model as an argument rather than partial model bindings) does also work with from transitions import Machine
from functools import partial
from mock import MagicMock
class Model(object):
machine = Machine(model=None, states=['A', 'B', 'C'], initial=None,
transitions=[
{'trigger': 'go', 'source': 'A', 'dest': 'B', 'before': 'before'},
{'trigger': 'check', 'source': 'B', 'dest': 'C', 'conditions': 'is_large'},
], finalize_event='finalize')
def __init__(self):
self.state = 'A'
self.before = MagicMock()
self.after = MagicMock()
self.finalize = MagicMock()
@staticmethod
def is_large(value=0):
return value > 9000
def __getattribute__(self, item):
try:
return super(Model, self).__getattribute__(item)
except AttributeError:
if item in self.machine.events:
return partial(self.machine.events[item].trigger, self)
raise
model = Model()
model.go()
assert model.state == 'B'
assert model.before.called
assert model.finalize.called
model.check()
assert model.state == 'B'
model.check(value=500)
assert model.state == 'B'
model.check(value=9001)
assert model.state == 'C'
assert model.finalize.call_count == 4 At a first glance, the only functionality you lose (compared to adding the model to the machine) is the convenience function |
@aleneum Thanks for your serious work! Nice way to make "self" behave as model by passing to the event as parameter. |
solutions presented here will be moved to the example section. |
I started writing a
|
I pushed the package to pypi https://pypi.org/project/django-transitions/ |
@aleneum Great work, thank you! It does break the class DynamicState(State):
'''
Need to dynamically get the on_enter and on_exit callbacks since the
model can not be registered to the Machine due to Memory limitations
'''
def enter(self, event_data):
""" Triggered when a state is entered. """
logger.debug("%sEntering state %s. Processing callbacks...", event_data.machine.name, self.name)
if hasattr(event_data.model, f'on_enter_{self.name}'):
event_data.machine.callbacks([getattr(event_data.model, f'on_enter_{self.name}')], event_data)
logger.info("%sFinished processing state %s enter callbacks.", event_data.machine.name, self.name)
def exit(self, event_data):
""" Triggered when a state is exited. """
logger.debug("%sExiting state %s. Processing callbacks...", event_data.machine.name, self.name)
if hasattr(event_data.model, f'on_exit_{self.name}'):
event_data.machine.callbacks([getattr(event_data.model, f'on_exit_{self.name}')], event_data)
logger.info("%sFinished processing state %s exit callbacks.", event_data.machine.name, self.name)
class DynamicMachine(Machine):
'''Required to use DynamicState'''
state_cls = DynamicState Edit (2021-07-24): This breaks calls to self.machine.set_state(new_state, model=self) |
Hello @mvanderlee, good remark 👍
I guess you mean callbacks defined on the model. On_enter/exit callbacks defined on states (such as But considering models you are absolutely right! You lose If memory footprint is critical one could consider moving callbacks to |
Just a note on that. Our footprint dropped from > 1 GB for a 1000 objects to < 5MB. That's no longer an 'if critical' issue. Our use-case involved syncing databases with custom processing logic, for database performance reasons we needed to work in large batches. We created an SQLAlchemy model with a Machine. Using memory-profiler we found that our instance creation step took 1.25 GB. when I updated the class with the above mentioned changes it dropped to 3.3 MB. When I replaced the class with a native dict without pytransitions as a benchmark, it dropped to 1.2 MB. |
Thanks a lot for taking the time to write that down. Much appreciated! |
Just a note for someone who is referring this for Python 3, below will not work and will keep creating new instances of Machine as
Below will work in Python 3:
|
Just reading through, I'm just trying to understand why the default usage pattern for transitions is duplicate the whole machine setup on instantiation of every model object. I don't understand why that pattern is required for any use case? Does the machine instance contain any further mutable state than the value of I guess, what I mean is why not create a new standard way of using the package with internal plumbing based around your singleton example, @aleneum and deprecate the current approach? |
Hello @Adamantish
Sure, there is the 'beginners/initial' way where a machine also acts as a model. This is usually sufficient for many simple use cases. As this thread shows, this is not suitable for django or any other approach involving databases and a large number of models. If you plan to use a large number of models, I'd say the 'default' pattern is to have one machine and multiple models. This 'separation of concern' is already illustrated in the first example of the documentation. States, Events and Transitions are not duplicated in this case. Every model get some convenience functions assigned when it is added to the machine. The number of convenience functions depends on how many events (transition names) have been added to the machine. This causes some overhead that is neglectable for most use cases. However, if you plan to use thousands of models it might become a notable memory impact as well. If so, skipping model bindings could tackle this.
it does contain the list of models, events, transitions and events (and graphs). This can be updated at runtime and will effect all currently managed models immediately.
With |
We documented the results of this discussion in the FAQ. Make sure to check it out!
Occasionally,
we discuss generic model functionality which might be handy for some users. Just to keep track of these requests, here is the current candidate list:
edit: I split the list into separate comments. This way it might be easier to "upvote" the extension you are looking forward to.
Please comment/"upvote" if you like to see any of these features integrated into
transitions
Already implemented:
0.6.0
:The text was updated successfully, but these errors were encountered: