popoto.fields.tag_field¶
popoto.fields.tag_field
¶
Tag Field - Optional Multi-Value Scoping via Redis Sets¶
This module provides TagFieldMixin, the multi-value generalization of
:class:IndexedFieldMixin. Where an indexed field maps one record to exactly
one value-Set, a tag field maps one record to many value-Sets at once — one
per tag value — so a record can belong to several optional scopes simultaneously
(or to none at all).
Motivation¶
A centrally hosted Redis/Valkey may serve many agents. A memory can be scoped by
the agent it belongs to (agent:valor), a relevant project (project:popoto),
or arbitrary bare tags the agents agree on — and every scoping dimension is
optional. KeyField partitioning cannot express this: a partition value becomes
part of the record's identity and is required at query time. Tags are metadata,
not identity — a record with zero tags transparently lives in the shared pool and
is returned by unscoped queries.
Design Philosophy¶
- Convention over schema. popoto stays agnostic about which dimensions exist.
Agents cooperate on prefixes (
agent:,project:) or use bare tags; the field imposes no schema on tag namespaces. - Not a security boundary. Tags are cooperative scoping between trusted agents, not access control. Nothing here enforces isolation — a query with the right tag filter can read any tagged record.
- Valkey-safe. Index maintenance and queries use only core Redis Set commands
(
SADD/SREM/SMEMBERS/SUNION/SINTER/DEL) — no Redis modules.
Index Structure¶
Per tag value, a Redis Set of instance keys::
$TagF:ModelName:field_name:tag_value -> Set of redis_keys
(The $TagF prefix is auto-derived by the FieldBase metaclass from the class
name TagField.) Tag values containing the : DB_key separator — e.g.
agent:valor — are escaped by DB_key.clean (agent{:}valor), so
convention prefixes never collide with the key structure.
Atomic multi-value maintenance¶
Because a record belongs to N Sets at once, index maintenance rides a dedicated
atomic Lua script, :data:TAG_SWAP_LUA, that diffs the record's previous tag
membership against the new one and issues only the necessary SREM/SADD
calls — all inside a single server-side EVAL (no cross-process race, no
orphaned members). The previous membership is read from a server-authoritative
pointer side key (a standalone Redis Set of the value-Set keys the record
currently belongs to), never from a client-side snapshot — the same #476 lesson
that made :data:INDEX_SWAP_LUA use a side key instead of an in-hash pointer.
TagFieldMixin subclasses :class:IndexedFieldMixin so that
isinstance(field, IndexedFieldMixin) remains true: this makes Model.save()
(a) exclude the tag field from the plain HSET mapping — :data:TAG_SWAP_LUA owns
the hash write — and (b) run the field eagerly on its own atomic EVAL before
the surrounding pipeline, closing the same unique-conflict window as #476. All four
hook methods are fully overridden for multi-value semantics.
Usage¶
from popoto import Model, AutoKeyField, TagField
class Memory(Model):
key = AutoKeyField()
tags = TagField() # optional; zero tags == shared pool
Memory.create(tags=["agent:valor", "project:popoto"])
Memory.create() # untagged — lives in the shared pool
Memory.query.filter(tags__contains="agent:valor") # membership
Memory.query.filter(tags__any=["agent:a", "agent:b"]) # OR (SUNION)
Memory.query.filter(tags__all=["agent:valor",
"project:popoto"]) # AND (SINTER)
TagFieldMixin
¶
Bases: IndexedFieldMixin
Mixin that provides optional, indexed, multi-value scoping via Redis Sets.
A record may carry any number of tag values (or none). For each tag value a
Redis Set tracks the instance keys carrying it, enabling membership / any-of
(SUNION) / all-of (SINTER) filtering that composes with the rest of
Query.filter() through the same set-intersection pipeline.
Subclasses :class:IndexedFieldMixin purely so Model.save() treats it as
an atomic-index field (HSET exclusion + eager EVAL); all behavior is overridden
for multi-value semantics. Tags are never part of the record's Redis key.
Attributes:
| Name | Type | Description |
|---|---|---|
tag |
bool
|
Always True for tag fields. |
Source code in src/popoto/fields/tag_field.py
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 | |
is_valid(field, value, null_check=True, **kwargs)
classmethod
¶
Validate that a value is an optional collection of scalar tags.
Source code in src/popoto/fields/tag_field.py
format_value_pre_save(field_value, **kwargs)
¶
Normalize to a sorted unique list before serialization.
Model.save() writes this result back onto the instance before encoding,
so the model hash (and any in-memory reads after save) see a stable list
even if the caller assigned a set or tuple.
Source code in src/popoto/fields/tag_field.py
on_save(model_instance, field_name, field_value, pipeline=None, **kwargs)
classmethod
¶
Atomically diff-and-update the per-tag Sets via :data:TAG_SWAP_LUA.
Internal path (no pipeline): runs the EVAL directly against
POPOTO_REDIS_DB — atomic on the server, eager (before the surrounding
save pipeline) exactly like IndexedFieldMixin. External path: queues the
EVAL into the caller's pipeline so the hash write and index update commit
together in one MULTI/EXEC.
Source code in src/popoto/fields/tag_field.py
on_delete(model_instance, field_name, field_value, pipeline=None, **kwargs)
classmethod
¶
Remove the record from every tag Set it belongs to, then drop the pointer.
Reads the authoritative membership from the pointer side key (still present because Model.delete() runs field hooks before the hash DELETE). Falls back to the field value if the pointer is somehow absent (legacy / partial state).
Source code in src/popoto/fields/tag_field.py
get_filter_query_params(field_name)
¶
Valid tag lookups: membership / any-of / all-of.
Deliberately does NOT inherit IndexedFieldMixin's single-value lookups
(exact match, __in, __startswith ...): the stored value is a list,
so exact-match on it is meaningless. Absent any of these params, a query
never routes to this field and results stay unscoped (shared pool).
Source code in src/popoto/fields/tag_field.py
filter_query(model, field_name, **query_params)
classmethod
¶
Resolve tag lookups to matching Redis keys via plain Set commands.
__contains=v→SMEMBERSof the single value Set.__any=[...]→SUNIONof the value Sets (OR).__all=[...]→SINTERof the value Sets (AND).
Multiple tag params AND-intersect client-side, consistent with the rest of
filter_for_keys_set. Empty any/all lists yield an empty match (no crash).