Implement DbPrototype caching, refactor. Resolve #2792

This commit is contained in:
Griatch 2022-10-30 12:13:13 +01:00
parent 36006f3fe9
commit f9ca50ba5f
3 changed files with 190 additions and 139 deletions

View file

@ -207,6 +207,9 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
powerful searches passed into the regular search functions. powerful searches passed into the regular search functions.
- `spawner.spawn` and linked methods now has a kwarg `protfunc_raise_errors` - `spawner.spawn` and linked methods now has a kwarg `protfunc_raise_errors`
(default True) to disable strict errors on malformed/not-found protfuncs (default True) to disable strict errors on malformed/not-found protfuncs
- Improve search performance when having many DB-based prototypes via caching.
- Remove the `return_parents` kwarg of `evennia.prototypes.spawner.spawn` since it
was inefficient and unused.
## Evennia 0.9.5 ## Evennia 0.9.5

View file

@ -21,9 +21,15 @@ from evennia.utils.create import create_script
from evennia.utils.evmore import EvMore from evennia.utils.evmore import EvMore
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from evennia.utils.funcparser import FuncParser from evennia.utils.funcparser import FuncParser
from evennia.utils.utils import (all_from_module, class_from_module, from evennia.utils.utils import (
dbid_to_obj, is_iter, justify, make_iter, all_from_module,
variable_from_module) class_from_module,
dbid_to_obj,
is_iter,
justify,
make_iter,
variable_from_module,
)
_MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPE_MODULES = {}
_MODULE_PROTOTYPES = {} _MODULE_PROTOTYPES = {}
@ -57,23 +63,6 @@ _PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()"
FUNC_PARSER = FuncParser(settings.PROT_FUNC_MODULES) FUNC_PARSER = FuncParser(settings.PROT_FUNC_MODULES)
class DBPrototypeCache:
def __init__(self):
self._cache = {}
def get(self, db_prot_id):
return self._cache.get(db_prot_id, None)
def add(self, db_prot_id, prototype):
self._cache[db_prot_id] = prototype
def remove(self, db_prot_id):
self._cache.pop(db_prot_id, None)
DB_PROTOTYPE_CACHE = DBPrototypeCache()
class PermissionError(RuntimeError): class PermissionError(RuntimeError):
pass pass
@ -312,7 +301,7 @@ def load_module_prototypes(*mod_or_prototypes, override=True):
prototype_key = mod_or_dict.get("prototype_key") prototype_key = mod_or_dict.get("prototype_key")
if not prototype_key: if not prototype_key:
raise ValidationError( raise ValidationError(
f"The prototype {mod_or_prototype} does not contain a 'prototype_key'" f"The prototype {mod_or_dict} does not contain a 'prototype_key'"
) )
prots = [(prototype_key, mod_or_dict)] prots = [(prototype_key, mod_or_dict)]
mod = None mod = None
@ -341,6 +330,36 @@ def load_module_prototypes(*mod_or_prototypes, override=True):
# Db-based prototypes # Db-based prototypes
class DBPrototypeCache:
"""
Cache DB-stored prototypes; it can still be slow to initially load 1000s of
prototypes, due to having to deserialize all prototype-dicts, but after the
first time the cache will be populated and things will be fast.
"""
def __init__(self):
self._cache = {}
def get(self, db_prot_id):
return self._cache.get(db_prot_id, None)
def add(self, db_prot_id, prototype):
self._cache[db_prot_id] = prototype
def remove(self, db_prot_id):
self._cache.pop(db_prot_id, None)
def clear(self):
self._cache = {}
def replace(self, all_data):
self._cache = all_data
DB_PROTOTYPE_CACHE = DBPrototypeCache()
class DbPrototype(DefaultScript): class DbPrototype(DefaultScript):
""" """
This stores a single prototype, in an Attribute `prototype`. This stores a single prototype, in an Attribute `prototype`.
@ -450,7 +469,7 @@ def save_prototype(prototype):
tags=in_prototype["prototype_tags"], tags=in_prototype["prototype_tags"],
attributes=[("prototype", in_prototype)], attributes=[("prototype", in_prototype)],
) )
DB_PROTOTYPE_CACHE.add(stored_prototype.prototype) DB_PROTOTYPE_CACHE.add(stored_prototype.id, stored_prototype.prototype)
return stored_prototype.prototype return stored_prototype.prototype
@ -495,13 +514,19 @@ def delete_prototype(prototype_key, caller=None):
"delete prototype {prototype_key}." "delete prototype {prototype_key}."
).format(caller=caller, prototype_key=prototype_key) ).format(caller=caller, prototype_key=prototype_key)
) )
DB_PROTOTYPE_CACHE.remove(stored_prototype.prototype) DB_PROTOTYPE_CACHE.remove(stored_prototype.id)
stored_prototype.delete() stored_prototype.delete()
return True return True
def search_prototype( def search_prototype(
key=None, tags=None, require_single=False, return_iterators=False, no_db=False key=None,
tags=None,
require_single=False,
return_iterators=False,
no_db=False,
page_size=None,
page_no=None,
): ):
""" """
Find prototypes based on key and/or tags, or all prototypes. Find prototypes based on key and/or tags, or all prototypes.
@ -525,7 +550,7 @@ def search_prototype(
no match was found. Note that if neither `key` nor `tags` no match was found. Note that if neither `key` nor `tags`
were given, *all* available prototypes will be returned. were given, *all* available prototypes will be returned.
list, queryset: If `return_iterators` are found, this is a list of list, queryset: If `return_iterators` are found, this is a list of
module-based prototypes followed by a *paginated* queryset of module-based prototypes followed by a queryset of
db-prototypes. db-prototypes.
Raises: Raises:
@ -537,6 +562,12 @@ def search_prototype(
tags are given and the prototype has no tags defined, it will not tags are given and the prototype has no tags defined, it will not
be found as a match. be found as a match.
"""
def _search_module_based_prototypes(key, tags):
"""
Helper function to load module-based prots.
""" """
# This will load the prototypes the first time they are searched # This will load the prototypes the first time they are searched
loaded = getattr(load_module_prototypes, "_LOADED", False) loaded = getattr(load_module_prototypes, "_LOADED", False)
@ -544,10 +575,6 @@ def search_prototype(
load_module_prototypes() load_module_prototypes()
setattr(load_module_prototypes, "_LOADED", True) setattr(load_module_prototypes, "_LOADED", True)
# prototype keys are always in lowecase
if key:
key = key.lower()
# search module prototypes # search module prototypes
mod_matches = {} mod_matches = {}
@ -562,12 +589,12 @@ def search_prototype(
else: else:
mod_matches = _MODULE_PROTOTYPES mod_matches = _MODULE_PROTOTYPES
allow_fuzzy = True fuzzy_match_db = True
if key: if key:
if key in mod_matches: if key in mod_matches:
# exact match # exact match
module_prototypes = [mod_matches[key].copy()] module_prototypes = [mod_matches[key].copy()]
allow_fuzzy = False fuzzy_match_db = False
else: else:
# fuzzy matching # fuzzy matching
module_prototypes = [ module_prototypes = [
@ -580,62 +607,73 @@ def search_prototype(
# prototype_from_object will modify the base prototype for every object # prototype_from_object will modify the base prototype for every object
module_prototypes = [match.copy() for match in mod_matches.values()] module_prototypes = [match.copy() for match in mod_matches.values()]
if no_db: return module_prototypes, fuzzy_match_db
db_matches = []
else: def _search_db_based_prototypes(key, tags, fuzzy_matching):
"""
Helper function for loading db-based prots.
"""
# search db-stored prototypes # search db-stored prototypes
if tags: if tags:
# exact match on tag(s) # exact match on tag(s)
tags = make_iter(tags) tags = make_iter(tags)
tag_categories = ["db_prototype" for _ in tags] tag_categories = ["db_prototype" for _ in tags]
db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) query = DbPrototype.objects.get_by_tag(tags, tag_categories)
else: else:
db_matches = DbPrototype.objects.all() query = DbPrototype.objects.all()
if key: if key:
# exact or partial match on key # exact or partial match on key
exact_match = db_matches.filter(Q(db_key__iexact=key)).order_by("db_key") exact_match = query.filter(Q(db_key__iexact=key))
if not exact_match and allow_fuzzy: if not exact_match and fuzzy_matching:
# try with partial match instead # try with partial match instead
db_matches = db_matches.filter(Q(db_key__icontains=key)).order_by("db_key") query = query.filter(Q(db_key__icontains=key))
else: else:
db_matches = exact_match query = exact_match
db_ids = db_matches.values_list("id", flat=True)
db_matches = Attribute.objects.filter(scriptdb__pk__in=db_ids, db_key="prototype")
.values_list("db_value", flat=True)
.order_by("scriptdb__db_key")
# convert to prototype, cached or from db
db_protkeys = db_matches.values_list("db_key", flat=True) db_matches = []
# convert to prototype not_found = []
cache = DB_PROTOTYPE_CACHE.get() for db_id in query.values_list("id", flat=True).order_by("db_key"):
db_matches = [cache.get(protkey) for protkey in db_protkeys if protkey in cache] prot = DB_PROTOTYPE_CACHE.get(db_id)
if prot:
db_matches.append(prot)
else: else:
# fetch and deserialize all data not_found.append(db_id)
db_ids = db_matches.values_list("id", flat=True)
db_matches = ( if not_found:
Attribute.objects.filter(scriptdb__pk__in=db_ids, db_key="prototype") new_db_matches = (
Attribute.objects.filter(scriptdb__pk__in=not_found, db_key="prototype")
.values_list("db_value", flat=True) .values_list("db_value", flat=True)
.order_by("scriptdb__db_key") .order_by("scriptdb__db_key")
) )
for db_id, prot in zip(not_found, new_db_matches):
DB_PROTOTYPE_CACHE.add(db_id, prot)
db_matches.extend(list(new_db_matches))
return db_matches
if key:
key = key.lower()
module_prototypes, fuzzy_match_db = _search_module_based_prototypes(key, tags)
db_prototypes = [] if no_db else _search_db_based_prototypes(key, tags, fuzzy_match_db)
if key and require_single: if key and require_single:
nmodules = len(module_prototypes) num = len(module_prototypes) + len(db_prototypes)
ndbprots = db_matches.count() if db_matches else 0 if num != 1:
if nmodules + ndbprots != 1: raise KeyError(_(f"Found {num} matching prototypes."))
raise KeyError(
_("Found {num} matching prototypes among {module_prototypes}.").format(
num=nmodules + ndbprots, module_prototypes=module_prototypes
)
)
if return_iterators: if return_iterators:
# trying to get the entire set of prototypes - we must paginate # trying to get the entire set of prototypes - we must paginate
# the result instead of trying to fetch the entire set at once # the result instead of trying to fetch the entire set at once
return db_matches, module_prototypes return db_prototypes, module_prototypes
else: else:
# full fetch, no pagination (compatibility mode) # full fetch, no pagination (compatibility mode)
return list(db_matches) + module_prototypes return list(db_prototypes) + module_prototypes
def search_objects_with_prototype(prototype_key): def search_objects_with_prototype(prototype_key):
@ -686,7 +724,7 @@ class PrototypeEvMore(EvMore):
# of each. # of each.
n_mod = len(modprot_list) n_mod = len(modprot_list)
self._npages_mod = n_mod // self.height + (0 if n_mod % self.height == 0 else 1) self._npages_mod = n_mod // self.height + (0 if n_mod % self.height == 0 else 1)
self._db_count = dbprot_paged.count self._db_count = dbprot_paged.count if dbprot_paged else 0
self._npages_db = dbprot_paged.num_pages if self._db_count > 0 else 0 self._npages_db = dbprot_paged.num_pages if self._db_count > 0 else 0
# total number of pages # total number of pages
self._npages = self._npages_mod + self._npages_db self._npages = self._npages_mod + self._npages_db
@ -783,7 +821,7 @@ def list_prototypes(
dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True) dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True)
if not dbprot_query.count() and not modprot_list: if not dbprot_query and not modprot_list:
caller.msg(_("No prototypes found."), session=session) caller.msg(_("No prototypes found."), session=session)
return None return None
@ -807,8 +845,9 @@ def validate_prototype(
prototype (dict): Prototype to validate. prototype (dict): Prototype to validate.
protkey (str, optional): The name of the prototype definition. If not given, the prototype protkey (str, optional): The name of the prototype definition. If not given, the prototype
dict needs to have the `prototype_key` field set. dict needs to have the `prototype_key` field set.
protpartents (dict, optional): The available prototype parent library. If protparents (dict, optional): Additional prototype-parents, supposedly provided specifically
note given this will be determined from settings/database. for this prototype. If given, matching parents will first be taken from this
dict rather than from the global set of prototypes found via settings/database.
is_prototype_base (bool, optional): We are trying to create a new object *based on this is_prototype_base (bool, optional): We are trying to create a new object *based on this
object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent
etc. etc.
@ -822,16 +861,11 @@ def validate_prototype(
""" """
assert isinstance(prototype, dict) assert isinstance(prototype, dict)
protparents = {} if protparents is None else protparents
if _flags is None: if _flags is None:
_flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []}
if not protparents:
protparents = {
prototype.get("prototype_key", "").lower(): prototype
for prototype in search_prototype()
}
protkey = protkey and protkey.lower() or prototype.get("prototype_key", None) protkey = protkey and protkey.lower() or prototype.get("prototype_key", None)
if strict and not bool(protkey): if strict and not bool(protkey):
@ -883,11 +917,18 @@ def validate_prototype(
_flags["errors"].append( _flags["errors"].append(
_("Prototype {protkey} tries to parent itself.").format(protkey=protkey) _("Prototype {protkey} tries to parent itself.").format(protkey=protkey)
) )
# get prototype parent, first try custom set, then search globally
protparent = protparents.get(protstring) protparent = protparents.get(protstring)
if not protparent: if not protparent:
protparent = search_prototype(key=protstring, require_single=True)
if protparent:
protparent = protparent[0]
else:
_flags["errors"].append( _flags["errors"].append(
_( _(
"Prototype {protkey}'s `prototype_parent` (named '{parent}') was not found." "Prototype {protkey}'s `prototype_parent` (named '{parent}') was not"
" found."
).format(protkey=protkey, parent=protstring) ).format(protkey=protkey, parent=protstring)
) )
@ -906,7 +947,11 @@ def validate_prototype(
# next step of recursive validation # next step of recursive validation
validate_prototype( validate_prototype(
protparent, protstring, protparents, is_prototype_base=is_prototype_base, _flags=_flags protparent,
protkey=protstring,
protparents=protparents,
is_prototype_base=is_prototype_base,
_flags=_flags,
) )
_flags["visited"].pop() _flags["visited"].pop()
@ -967,7 +1012,8 @@ def protfunc_parser(
available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
If not set, use default sources. If not set, use default sources.
stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser. stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
raise_errors (bool, optional): Raise explicit errors from malformed/not found protfunc calls. raise_errors (bool, optional): Raise explicit errors from malformed/not found protfunc
calls.
Keyword Args: Keyword Args:
session (Session): Passed to protfunc. Session of the entity spawning the prototype. session (Session): Passed to protfunc. Session of the entity spawning the prototype.
@ -1117,8 +1163,10 @@ def check_permission(prototype_key, action, default=True):
logger.log_err(err.format(protkey=prototype_key, module=mod)) logger.log_err(err.format(protkey=prototype_key, module=mod))
return False return False
prototype = search_prototype(key=prototype_key) prototype = search_prototype(key=prototype_key, require_single=True)
if not prototype: if prototype:
prototype = prototype[0]
else:
logger.log_err("Prototype {} not found.".format(prototype_key)) logger.log_err("Prototype {} not found.".format(prototype_key))
return False return False

View file

@ -145,6 +145,7 @@ from evennia.prototypes import prototypes as protlib
from evennia.prototypes.prototypes import ( from evennia.prototypes.prototypes import (
PROTOTYPE_TAG_CATEGORY, PROTOTYPE_TAG_CATEGORY,
init_spawn_value, init_spawn_value,
search_prototype,
value_to_obj, value_to_obj,
value_to_obj_or_any, value_to_obj_or_any,
) )
@ -190,7 +191,7 @@ class Unset:
# Helper # Helper
def _get_prototype(inprot, protparents, uninherited=None, _workprot=None): def _get_prototype(inprot, protparents=None, uninherited=None, _workprot=None):
""" """
Recursively traverse a prototype dictionary, including multiple Recursively traverse a prototype dictionary, including multiple
inheritance. Use validate_prototype before this, we don't check inheritance. Use validate_prototype before this, we don't check
@ -198,7 +199,9 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
Args: Args:
inprot (dict): Prototype dict (the individual prototype, with no inheritance included). inprot (dict): Prototype dict (the individual prototype, with no inheritance included).
protparents (dict): Available protparents, keyed by prototype_key. protparents (dict): Custom protparents, supposedly provided specifically for this `inprot`.
If given, any parents will first be looked up in this dict, and then by searching
the global prototype store given by settings/db.
uninherited (dict): Parts of prototype to not inherit. uninherited (dict): Parts of prototype to not inherit.
_workprot (dict, optional): Work dict for the recursive algorithm. _workprot (dict, optional): Work dict for the recursive algorithm.
@ -220,6 +223,8 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
old.update(new) old.update(new)
return list(old.values()) return list(old.values())
protparents = {} if protparents is None else protparents
_workprot = {} if _workprot is None else _workprot _workprot = {} if _workprot is None else _workprot
if "prototype_parent" in inprot: if "prototype_parent" in inprot:
# move backwards through the inheritance # move backwards through the inheritance
@ -234,8 +239,12 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
# protparent already embedded as-is # protparent already embedded as-is
parent_prototype = prototype parent_prototype = prototype
else: else:
# protparent given by-name # protparent given by-name, first search provided parents, then global store
parent_prototype = protparents.get(prototype.lower(), {}) parent_prototype = protparents.get(prototype.lower())
if not parent_prototype:
parent_prototype = search_prototype(key=prototype.lower()) or {}
if parent_prototype:
parent_prototype = parent_prototype[0]
# Build the prot dictionary in reverse order, overloading # Build the prot dictionary in reverse order, overloading
new_prot = _get_prototype(parent_prototype, protparents, _workprot=_workprot) new_prot = _get_prototype(parent_prototype, protparents, _workprot=_workprot)
@ -277,14 +286,9 @@ def flatten_prototype(prototype, validate=False, no_db=False):
if prototype: if prototype:
prototype = protlib.homogenize_prototype(prototype) prototype = protlib.homogenize_prototype(prototype)
protparents = { protlib.validate_prototype(prototype, is_prototype_base=validate, strict=validate)
prot["prototype_key"].lower(): prot for prot in protlib.search_prototype(no_db=no_db)
}
protlib.validate_prototype(
prototype, None, protparents, is_prototype_base=validate, strict=validate
)
return _get_prototype( return _get_prototype(
prototype, protparents, uninherited={"prototype_key": prototype.get("prototype_key")} prototype, uninherited={"prototype_key": prototype.get("prototype_key")}
) )
return {} return {}
@ -661,6 +665,8 @@ def batch_update_objects_with_prototype(
if isinstance(prototype, str): if isinstance(prototype, str):
new_prototype = protlib.search_prototype(prototype) new_prototype = protlib.search_prototype(prototype)
if new_prototype:
new_prototype = new_prototype[0]
else: else:
new_prototype = prototype new_prototype = prototype
@ -892,10 +898,6 @@ def spawn(*prototypes, caller=None, **kwargs):
prototype_parents (dict): A dictionary holding a custom prototype_parents (dict): A dictionary holding a custom
prototype-parent dictionary. Will overload same-named prototype-parent dictionary. Will overload same-named
prototypes from prototype_modules. prototypes from prototype_modules.
return_parents (bool): Return a dict of the entire prototype-parent tree
available to this prototype (no object creation happens). This is a
merged result between the globally found protparents and whatever
custom `prototype_parents` are given to this function.
only_validate (bool): Only run validation of prototype/parents only_validate (bool): Only run validation of prototype/parents
(no object creation) and return the create-kwargs. (no object creation) and return the create-kwargs.
protfunc_raise_errors (bool): Raise explicit exceptions on a malformed/not-found protfunc_raise_errors (bool): Raise explicit exceptions on a malformed/not-found
@ -903,8 +905,7 @@ def spawn(*prototypes, caller=None, **kwargs):
Returns: Returns:
object (Object, dict or list): Spawned object(s). If `only_validate` is given, return object (Object, dict or list): Spawned object(s). If `only_validate` is given, return
a list of the creation kwargs to build the object(s) without actually creating it. If a list of the creation kwargs to build the object(s) without actually creating it.
`return_parents` is set, instead return dict of prototype parents.
""" """
# search string (=prototype_key) from input # search string (=prototype_key) from input
@ -913,9 +914,6 @@ def spawn(*prototypes, caller=None, **kwargs):
for prot in prototypes for prot in prototypes
] ]
# get available protparents
protparents = {prot["prototype_key"].lower(): prot for prot in protlib.search_prototype()}
if not kwargs.get("only_validate"): if not kwargs.get("only_validate"):
# homogenization to be more lenient about prototype format when entering the prototype # homogenization to be more lenient about prototype format when entering the prototype
# manually # manually
@ -924,21 +922,23 @@ def spawn(*prototypes, caller=None, **kwargs):
# overload module's protparents with specifically given protparents # overload module's protparents with specifically given protparents
# we allow prototype_key to be the key of the protparent dict, to allow for module-level # we allow prototype_key to be the key of the protparent dict, to allow for module-level
# prototype imports. We need to insert prototype_key in this case # prototype imports. We need to insert prototype_key in this case
custom_protparents = {}
for key, protparent in kwargs.get("prototype_parents", {}).items(): for key, protparent in kwargs.get("prototype_parents", {}).items():
key = str(key).lower() key = str(key).lower()
protparent["prototype_key"] = str(protparent.get("prototype_key", key)).lower() protparent["prototype_key"] = str(protparent.get("prototype_key", key)).lower()
protparents[key] = protlib.homogenize_prototype(protparent) custom_protparents[key] = protlib.homogenize_prototype(protparent)
if "return_parents" in kwargs:
# only return the parents
return copy.deepcopy(protparents)
objsparams = [] objsparams = []
for prototype in prototypes: for prototype in prototypes:
protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) # run validation and homogenization of provided prototypes
protlib.validate_prototype(
prototype, None, protparents=custom_protparents, is_prototype_base=True
)
prot = _get_prototype( prot = _get_prototype(
prototype, protparents, uninherited={"prototype_key": prototype.get("prototype_key")} prototype,
protparents=custom_protparents,
uninherited={"prototype_key": prototype.get("prototype_key")},
) )
if not prot: if not prot:
continue continue