Invalidation Rules¶
InvalidationRule connects a Django model's post_save and
post_delete signals to a cached function. When the rule fires, the
wrapper either drops or refreshes the relevant cache entries.
Rules are passed to @cache_result(invalidates_on=[...]). Each rule
points at one model.
Field mapping¶
The most common shape is one model field mapping to one wrapper keyword argument.
from restflow.caching import cache_result, InvalidationRule
@cache_result(
key_constructor=UserPayloadKey,
ttl=300,
invalidates_on=[
InvalidationRule(
model=User,
field_mapping={"user_id": "id"},
),
],
)
def get_user_payload(user_id: int): ...
field_mapping={"user_id": "id"} reads as: when a User is saved or
deleted, take its id attribute and call the wrapper's
delete_by_prefix(user_id=<id>).
The wrapper falls back to delete_cache(...) when the targeted
argument is not part of the partition. Partition-only fields use
delete_by_prefix, and non-partition fields use the exact-key form.
require_args¶
require_args controls whether the rule should run when fields in
field_mapping resolve to None on the saving instance. Three
forms:
| Value | Behaviour |
|---|---|
True (default) |
Every mapped field must be non-null. If any resolves to None, the rule is skipped silently. Safe choice that prevents accidentally invalidating the None partition. |
False |
None values pass through into the wrapper's kwargs, so the rule can target the team_id=None partition or similar. The rule runs regardless. |
list[str] |
Only the named fields are required. Any other field may resolve to None and pass through. The rule is skipped silently if any listed field is None. |
InvalidationRule(
model=Membership,
field_mapping={"user_id": "user_id", "team_id": "team_id"},
require_args=["user_id"],
)
This rule runs whenever user_id is set, even if team_id is
None.
Custom invalidator¶
For transforms, derived values, multiple invalidations per save, or
custom routing, set invalidator to a callable (or a dotted-path
string).
def invalidate_user_caches(wrapper, instance, **_):
wrapper.delete_by_prefix(user_id=instance.id)
if instance.team_id:
wrapper.delete_by_prefix(team_id=instance.team_id)
InvalidationRule(
model=User,
invalidator=invalidate_user_caches,
)
The invalidator receives (wrapper, instance, **extras). The
extras dict may include signal_type, instance_created, and
update_fields depending on what the function declares (or whether
it accepts **kwargs).
field_mapping and invalidator are mutually exclusive on a single
rule. Setting both raises ValueError at construction time.
A dotted-path string is resolved lazily on the first call:
Pre-save gates¶
Three attributes filter when a rule runs based on the save itself. They apply to both the field-mapping path and the custom-invalidator path.
trigger_on_create¶
By default, rules do not run on the post_save signal that follows
Model.objects.create(). The reasoning is that there is nothing to
invalidate yet for a freshly created row. Set trigger_on_create=True
when the cache spans the whole table, for example a "list all
users" cache.
watch_fields¶
When set, post_save only fires the rule if the save's
update_fields includes one of the listed field names. The default
value None means every save fires the rule.
InvalidationRule(
model=User,
field_mapping={"user_id": "id"},
watch_fields=["email", "username"],
)
update_fields is the argument passed to Model.save(update_fields=).
Saves without update_fields go through every rule regardless.
invalidate_when¶
A mapping from attribute name to expected value. The rule fires only
when every entry matches the saving instance. Prefix a key with !
to negate the comparison.
InvalidationRule(
model=Article,
field_mapping={"article_id": "id"},
invalidate_when={"status": "published", "!archived": True},
)
Refresh instead of delete¶
Set rewarm=True to recompute and re-cache the value instead of
dropping it. Useful for hot keys where the next request would just
recompute anyway.
Rewarm needs every required function argument
Rewarming calls the wrapped function with the kwargs built from
field_mapping. The function must be callable with only those
kwargs. If the wrapped function takes additional required
arguments that the model instance cannot supply, rewarming raises
a binding error and the rule falls back to deletion (or to logging
the error, depending on raise_exception).
@cache_result(
key_constructor=AKey, # `a` is the partition key
invalidates_on=[
InvalidationRule(
model=A,
field_mapping={"a": "id"},
rewarm=True,
),
],
)
def result(a, b, c, d): ...
field_mapping only resolves a from the model. The wrapper then
tries result(a=<id>) for the rewarm and binding fails because
b, c, and d have no defaults and no source.
Choices when this comes up:
- Drop
rewarm=True. The rule deletes the partition withdelete_by_prefix(a=<id>)and the next call repopulates with the real arguments. This is the default and almost always the right answer for functions whose other arguments come from the request. - Give every non-partition argument a default the rewarm can
use, so
result(a=<id>)is a valid call that produces a meaningful cached value. - Use a custom
invalidator=that does the rewarm by callingwrapper.refresh(...)with every argument it can reconstruct, including ones derived from the saved instance.
The same constraint applies to @cache_response: rewarming a
view method needs the full call signature, but invalidation only
has the model fields. Prefer deletion for view-method caches and
let the next request rebuild the response.
Choosing a dispatcher¶
Each rule can pick its own dispatcher. The dispatcher decides where the invalidation work runs.
InvalidationRule(
model=User,
field_mapping={"user_id": "id"},
dispatcher="celery",
dispatcher_config={"queue": "cache-invalidation"},
)
dispatcher accepts either a registered name ("celery",
"django_rq", "django_q", "dramatiq", "asyncio",
"threadpool", "inline") or a Dispatcher subclass.
dispatcher_config is merged on top of the dispatcher's settings
block.
See the Dispatchers guide for per-dispatcher configuration.
Batching¶
batch=False by default. When set to True, this rule may share a
dispatch with other rules that have the same dispatcher batch key.
Off by default because a single batch failure retries the whole
group, which is rarely the desired behaviour for cache invalidation.
Error handling¶
Errors that escape the registry are caught and logged by default, so
a transient failure in a worker does not crash the model save. To
make errors propagate (so a broker can retry or dead-letter), set
raise_exception=True on the rule, on the dispatcher's settings, or
globally.
The resolution order, highest priority first:
InvalidationRule.raise_exception.- The per-dispatcher
RAISE_EXCEPTIONsetting. RESTFLOW_SETTINGS["CACHE_SETTINGS"]["DISPATCHER_RAISE_EXCEPTION"](defaultFalse).
When a batch mixes explicit values, True wins so the error
surfaces.
Where to next¶
- Dispatchers: pick where invalidation runs and configure each broker.
- cache_result: the decorator that pairs with these rules.
- Settings: tune the dispatcher defaults globally.