Writing Custom Fields¶
Popoto's field types (Field, SortedField, KeyField, GeoField, and the rest)
all derive from a single Field base class. Subclassing Field directly is how you
add a new storage or indexing behavior that the built-in field types do not cover —
for example a field that maintains its own companion hash in Redis alongside the
model's primary hash, the way ConfidenceField and CyclicDecayField do.
Note
Custom fields that override hooks are Redis-only for now; a planned Postgres backend (#759) will refuse them when a model is defined.
This page documents the contract a Field subclass should follow, with a focus on
the round-trip protocol every field author must satisfy: roundtrip_policy,
roundtrip_note, export_state, import_state, and remap_references. These five
members exist so that popoto.transfer — the export/import
driver — can move records between Redis instances without losing state that only your
field knows how to serialize. The first four cover state; the fifth covers references,
and is only consulted when the import regenerates keys.
The on_save hook¶
Most custom fields extend behavior through on_save, a classmethod called after the
model instance's primary hash is written:
from popoto import Field
class UppercaseField(Field):
@classmethod
def on_save(cls, model_instance, field_name, field_value, pipeline=None, **kwargs):
# maintain whatever secondary Redis structure this field needs
...
If your field's secondary structures are a pure function of the field's stored
value — an index entry, a sorted-set score, a geo-set member — on_save alone is
sufficient. A plain re-save (construct a new instance with the same values and call
save()) fully reconstructs that structure. This is the common case, and it is
exactly what roundtrip_policy = "rebuild" (the default) declares.
The pre_save_validate hook: refusing a save before anything is written¶
on_save is the wrong place to reject a save. By the time it runs on the
internal-pipeline paths, every IndexedFieldMixin field on the model has already
committed its hash value and its index entry against live Redis; raising there
leaves those writes behind. pre_save_validate exists for the check that has to
precede all of them:
from popoto import Field
class MonotonicField(Field):
@classmethod
def pre_save_validate(cls, model_instance, field_name, field_value, **kwargs):
# Raise to abort. Returning None (the default) allows the save.
...
It is dispatched once per field from a single site in Model.save(), after the
pre_save gate and above the partial/full split — which is above both eager
indexed-field phases and above the two external-pipeline arms. So a raise from
this hook means nothing at all was written or queued: not the model hash, not a
sibling field's index entry, not your own companion structure. On a partial save
the dispatch is scoped to update_fields, so a save of an unrelated column cannot
trip a validation your field owns.
Two rules follow from where it sits:
- Raise, don't return. The return value is ignored; a refusal is an exception.
Prefer a typed error subclassing
ValueError, so existingexcept (TypeError, ValueError)handlers aroundsave()keep working. - Implement it only for a check that must precede every write on the model, not
merely every write your field owns. A check that only needs to precede your own
structure belongs in
on_save. Declining a save (the never-record firewall, the write filter) is also not this hook's job — those paths decline by returning, and they run first.
ValidityField is the worked example: it uses pre_save_validate to refuse a
re-save that declares a valid_from the index disagrees with. See
Valid-time has one writer.
Lua scripts: use run_lua, not client.eval¶
A field that needs atomicity should run its script through
popoto.redis_db.run_lua, which takes the same arguments client.eval does:
import popoto
from popoto.redis_db import run_lua
run_lua(pipeline or popoto.get_redis(), MY_LUA, 2, key_a, key_b, arg_1)
Resolve the client at the point of use
from popoto.redis_db import POPOTO_REDIS_DB binds a copy of that name in
your module, frozen at import time. set_REDIS_DB_settings() rebinds the
original and Python does not propagate a rebind to a copy, so a field
holding one keeps writing to the pre-reconfiguration database while the rest
of the ORM has moved on — silently. popoto.get_redis() re-reads the global
on every call. Fields shipped before this was understood still hold
module-level imports;
#655 tracks converting
them.
run_lua caches a redis-py Script per script text and sends EVALSHA,
falling back to loading the source only when the server does not know the
hash. Calling client.eval directly still works, but it ships the whole
script body on every invocation — for a field whose hook runs on every save,
that is the script's byte size added to every write. Every shipped field was
converted in 1.9.0.
run_lua accepts a pipeline as its client, so the pipelined and immediate
branches of an on_save keep the shape they already have.
Your field's index namespace, and why it cannot collide¶
field_class_key — the $<Stem>F prefix your field's internal keys live
under — is derived from the class name with str.strip("Field"). That
strips a character set, not a suffix: FloatField lives under $oatF,
SortedField under $SortF. The spelling is on disk for every deployment,
yours included, so it is frozen; 1.9.0 does not change it.
What 1.9.0 does change is that two class names can no longer fold onto one
namespace silently. ModelField and MoField both strip to $MoF; defining
the second one now raises TypeError naming the class that already owns the
namespace, instead of letting both write into the same index keys. If you
hit that, give the newcomer an explicit namespace:
An explicit field_class_key is honored as-is and is the right tool
whenever the derived spelling is undesirable for a new class.
The round-trip obligation¶
Some fields maintain state that is not derivable from the field's stored value
alone — a running counter, a learned score, an amplitude that accumulated over many
strengthen/weaken calls. For those fields, on_save is not enough: reconstructing
the record from its plain value on a new Redis instance silently resets that state to
whatever on_save seeds it to.
Every Field subclass in src/popoto/fields/ must declare an explicit
roundtrip_policy. This is enforced by a test (tests/test_transfer_roundtrip.py)
that enumerates every field subclass in the package and asserts each declares a
policy, so a field with independent Redis state cannot silently ship with the
inherited "rebuild" default. Third-party fields outside the package are not
enforced by that test, but the obligation is the same: if your field holds state
on_save cannot reconstruct, declare it honestly.
roundtrip_policy is one of three values:
"rebuild"(default) — the field maintains no independent state that export needs to capture. Whateveron_savedoes on import is sufficient to fully reconstruct it. Correct for the vast majority of fields: indexes, sorted sets, geo indexes, and any structure that is a pure function of the stored value."carry"— the field maintains state that is not derivable from the stored value alone. A field declaring"carry"must implementexport_stateandimport_stateto serialize and restore that state explicitly."partial"— some state is carried or rebuilt, and some is knowingly not preserved. A field declaring"partial"must also setroundtrip_note.
Deciding between "carry" and "partial"¶
The line is not how hard the carry would be to write. It is what the bytes mean:
Carry a structure when the exported bytes are a fact about the record that the destination can restore verbatim. Do not carry one that is a property of the source deployment's history, which the destination never lived through.
Three questions settle it. If the answer to any is no, the structure does not carry:
- Is it decomposable to this record? A Bloom bit or a Count-Min counter is
shared by every value that collides onto it, so no record owns a share of it —
which is exactly why
on_deleteis a no-op for both. - Can the destination restore it without replaying anything? If reaching the
exported value means calling the field's own mutators (
strengthen,resolve,confirm), you are inventing a sequence of events to arrive at a number. Write the structure raw, or do not write it. - Is rebuilding actually worse? Sometimes it is better. A Bloom filter rebuilt from the imported records is more accurate than a carried one, which would hold bits for records that were never imported.
A carrier that answers all three must write raw Redis commands, not go through the field's public API — the public API stamps fresh timestamps and fires feedback hooks that would double-count against state carried independently by other fields.
Writing the note¶
roundtrip_note is read by someone deciding whether an import is safe for their
data. State the destination's contract — what they end up with ("frequencies
restart from the import") — not what the code declines to do ("counters not
carried"). Cite a tracking issue only while the gap is genuinely open work; a
permanent limitation should say so, because a lingering issue number invites a
future reader to "finish" a decision that was already made. See
Export and import
for how Popoto's own subsystems came down on each side of this.
import popoto
from popoto import Field
class LearnedScoreField(Field):
roundtrip_policy = "carry"
@classmethod
def export_state(cls, model_instance, field_name, field_value, **kwargs):
"""Return this field's auxiliary state, or None if there is nothing to carry."""
raw = popoto.get_redis().hget(
cls._data_hash_key(model_instance), field_name
)
if raw is None:
return None
return {"score": float(raw)}
@classmethod
def import_state(cls, model_instance, field_name, state, **kwargs):
"""Restore state after the record has been constructed and saved."""
if state is None:
return
popoto.get_redis().hset(
cls._data_hash_key(model_instance), field_name, state["score"]
)
export_state is called once per field per exported record, after the field's plain
value has already been captured by to_dict(). Read whatever secondary Redis
structure your field owns and return it as a JSON-serializable dict, or None if,
for this particular instance, there is nothing to carry. The base implementation
always returns None, which is correct for "rebuild" fields.
import_state is called once per field per imported record, after the instance
has been constructed and saved — so any structure on_save already rebuilt exists
and can be overwritten or supplemented. state is whatever export_state returned
for this field on the exporting side, or None if nothing was carried. The base
implementation is a no-op, which is correct whenever there is no carried state to
restore.
Both methods resolve their own configuration from
model_instance._meta.fields[field_name] rather than taking it as an argument,
mirroring on_save's existing shape.
The reference-remapping hook¶
roundtrip_policy and the two state hooks are about state. A field whose stored
value is another record's redis_key has a second obligation, and it only comes due
when an import mints new keys — import_records(..., preserve_keys=False), or
popoto-transfer import --regenerate-keys. Every record gets a fresh key on that
path, so any value pointing at an old key points at nothing unless it is rewritten.
The fifth hook is where that rewrite happens:
@classmethod
def remap_references(cls, field_name, field_value, key_map, **kwargs):
"""Return this field's value with record references remapped, or as-is."""
return field_value
The base implementation returns the value unchanged, which is correct for every field
that stores no reference to another record — so the vast majority of fields, including
every "carry" field, need nothing here. Relationship is the only field Popoto ships
that overrides it, and its override is a one-line key_map.get(field_value, field_value).
key_map maps old redis_key to new redis_key for the records minted so far on this
run. It is passed before the instance is constructed: the rewritten value goes into
the record's values dict on its way into model_class(**values), so on_save rebuilds
any index off the new key with no extra work.
Two rules an override must honor:
- Rewrite the stored string; never dereference it.
Relationshipstores aredis_keystring and loads the related object lazily, precisely so a circular reference does not recurse forever. Hydrating the target here to inspect it would reintroduce that recursion, and the string rewrite is sufficient on its own —encode_popoto_model_objaccepts a plainredis_keystring as already being in storage format. - Never guess. Only a field that declares it holds a reference is remapped. A
plain
Field(type=str)an application happens to fill with another record's key is indistinguishable from ordinary text, so it is left alone and will dangle after regeneration. That partial guarantee is documented; a heuristic that scanned strings for things that "look like" keys would trade a loud, documented limitation for occasional silent corruption.
A target absent from key_map keeps its old key rather than being dropped or invented.
The import report counts and names those, so a dangling pointer is reported rather than
hidden — see
Regenerating keys on import.
Model-level state¶
The round-trip protocol also applies to Model-level mixins that are not Field
subclasses — for example a mixin that maintains its own top-level Redis key rather
than a field-scoped one. popoto.transfer walks type(instance).__mro__ and calls
export_state/import_state on any class that defines them as its own
attribute ("export_state" in cls.__dict__), with the same two-value shape minus
field_name:
class AuditedMixin:
roundtrip_policy = "carry"
def export_state(self):
return {"audit_log": self._read_audit_log()}
def import_state(self, state):
if state:
self._write_audit_log(state["audit_log"])
State from a model-level mixin is keyed by the mixin's class name in the export
file, distinct from the field-keyed state above. Model itself defines neither
method, so a model with no such mixins contributes nothing at this level.
Why this matters¶
The driver holds no knowledge of any concrete field or mixin type — it never checks
isinstance against a named class. That is what lets a new field type work
correctly with zero changes to popoto.transfer. The cost of that genericity is
that it is entirely on the field author to declare roundtrip_policy honestly and
implement export_state/import_state when the default is wrong. A field that
declares "rebuild" while quietly growing independent state produces the exact
failure the export/import feature exists to prevent: an import report that confirms
success while auxiliary state was silently reset.
If you are unsure which policy applies, ask: "if I export this record, delete it,
and reconstruct it from the exported plain values with a normal save(), is
anything about this field's Redis footprint different from before?" If yes, that
field needs "carry" (fully) or "partial" (partly), plus the corresponding
export_state/import_state overrides.
See Export & Import for the user-facing guide to running an export/import and reading the resulting report.