Caching

django-moo uses a three-tier caching architecture to avoid redundant database queries during verb and property lookups. The tiers are, from fastest to slowest:

  1. Session cache — an in-process dict per ContextManager, valid for one command invocation

  2. Cross-session cache — a Redis-backed django.core.cache store, shared across requests

  3. AncestorCache table — a denormalized DB table that replaces recursive CTEs on the hot path


Tier 1: Session cache

Each ContextManager instance allocates two plain dicts when it is entered:

self.verb_lookup_cache = {}
self.prop_lookup_cache = {}

These are installed into contextvars (_verb_lookup_cache, _prop_lookup_cache) so they are accessible anywhere within the current async/thread context via ContextManager.get_verb_lookup_cache() and ContextManager.get_prop_lookup_cache(). They are reset to None when the ContextManager exits.

Verb session cache

Key: (object_pk, name, recurse, return_first) Value: the list of matching Verb objects, or None to record a confirmed miss.

_lookup_verb() checks this dict before touching the database. On a miss it populates the entry after the DB query resolves. add_verb() evicts all affected entries when a new verb is added to an object so the same session sees the change immediately.

Property session cache

Key: (object_pk, name, recurse) Value: the deserialized Python value, or _PROP_MISSING to record a confirmed miss.

get_property() checks this dict for non-original lookups (raw Property ORM objects are not cached here as they carry mutable ORM state). set_property() evicts the relevant entries on write.


Tier 2: Cross-session cache

When MOO_ATTRIB_CACHE_TTL > 0, results are also stored in Django’s configured cache backend (typically Redis in production). This lets warm results survive across separate requests and Celery tasks without hitting the database.

Configuration

moo.settings.base.MOO_ATTRIB_CACHE_TTL

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by ‘+’ or ‘-’ and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal. >>> int(‘0b100’, base=0) 4

Set MOO_ATTRIB_CACHE_TTL = 0 in test environments (see settings/test.py). The in-process LocMemCache does not reset between test cases, so a cached result from one test would poison subsequent tests when database PKs are reused after sequence resets.

Verb cross-session cache

Key: moo:verb:<object_pk>:<name>:<recurse>:<return_first> Value: a comma-separated string of Verb PKs, e.g. "42,17", or __moo:verb:missing__ for a confirmed miss.

PKs are stored rather than serialized Verb objects to avoid stale ORM state. On a cache hit, _lookup_verb() re-fetches the full objects with select_related and prefetch_related in a single query. Results are then stored in the session cache so subsequent lookups within the same command are free.

add_verb() calls cache.delete() for all combinations of (recurse, return_first) flags when a verb is created on an object, so the next lookup repopulates the cache cleanly.

Property cross-session cache

Key: moo:prop:<object_pk>:<name>:<recurse> Value: the raw moojson text of the property value, or __moo:prop:missing__ for a confirmed miss.

Raw moojson is stored rather than a deserialized value to avoid issues serializing Object references across processes. get_property() calls moojson.loads() on the cached string.

set_property() calls cache.delete() for both recurse variants when a property is written. Descendant caches are intentionally not invalidated — they expire naturally within MOO_ATTRIB_CACHE_TTL. This is an acceptable trade-off for gameplay: a brief window of staleness on inherited properties is preferable to the cost of walking the full descendant tree on every write.


Tier 3: AncestorCache table

AncestorCache is a denormalized flat table that replaces recursive CTEs on the hot path for both verb and property inheritance lookups.

Schema

AncestorCache.descendant

The descendant Object — the row says “this object inherits from ancestor at depth hops”.

AncestorCache.ancestor

The ancestor Object reachable from descendant.

AncestorCache.depth

Number of hops from descendant to ancestor. depth=1 is a direct parent, depth=2 is a grandparent, and so on.

AncestorCache.path_weight

Relationship.weight of the depth-1 link leading to this ancestor. Higher weight wins when multiple inheritance paths reach the same ancestor.

Indexed on (descendant, depth, path_weight) and (ancestor).

How it is used

_lookup_verb() and get_property() join against ancestor_descendants (the reverse relation from AncestorCache.ancestor) rather than issuing a recursive CTE:

Verb.objects.filter(
    origin__ancestor_descendants__descendant=self,
    names__name=name,
).annotate(
    ancestor_depth=F("origin__ancestor_descendants__depth"),
    path_weight=F("origin__ancestor_descendants__path_weight"),
).order_by("ancestor_depth", "-path_weight")

This is a single indexed JOIN rather than a recursive walk, which is significantly cheaper at dispatch time.

Parser.get_verb() uses the same table to dispatch verbs in a single batch: _batch_get_verb() issues two bulk queries against AncestorCache (one for direct verbs, one for inherited) and a third query to fetch the winning Verb objects, replacing the older sequential per-object loop with three round-trips total.

Maintenance

The table is kept consistent by the relationship_changed() signal, which fires on parents.add() and parents.remove(). On any topology change, _rebuild_ancestor_cache_for() deletes and recreates rows for the affected object and all its descendants. The rebuild itself uses a recursive CTE (via django-cte) to compute the correct depths and weights.

To rebuild the entire table after a bulk import or data migration:

docker compose run webapp manage.py rebuild_ancestor_cache

Sentinels

Four sentinel objects are used to distinguish cache states:

Name

Scope

Purpose

_PROP_MISSING

Session dict

Marks a confirmed property miss (distinguishes from a None value)

_CACHE_MISS

Cross-session cache

Returned by cache.get() when a key is absent; never stored

_CACHE_PROP_MISSING

Cross-session cache

Stored string marking a confirmed property miss

_CACHE_VERB_MISSING

Cross-session cache

Stored string marking a confirmed verb miss

_PROP_MISSING and _CACHE_MISS are unique Python objects compared with is; the string sentinels are serializable values stored in Redis.