Migrating to Familia 2.10
This version introduces the build factory block, atomic WATCH support, per-class dirty-write warning controls, and encryption envelope versioning. Most changes are additive; the breaking changes below affect dirty-write behavior and unique-index storage format.
Breaking Changes
Collection writes on unsaved parents now raise by default
Mutating a collection (list/set/zset/hashkey) while its parent Horreum has never been saved now raises Familia::Problem instead of emitting a warning. This prevents orphaned collection data when the parent is never saved.
# Before (2.9.x): warns only
user = User.new(email: "alice@example.com")
user..add("admin") # warning printed, SADD executes
# After (2.10.0): raises Familia::Problem
user = User.new(email: "alice@example.com")
user..add("admin") # raises — save the parent first
# Fix: save before mutating collections
user = User.new(email: "alice@example.com")
user.save
user..add("admin") # works
# Or use build to do it atomically
user = User.build(email: "alice@example.com") do |u|
u..add("admin") # folded into the same MULTI/EXEC as the save
end
To restore the old warn-only behavior:
Familia.configure do |config|
config.raise_on_unsaved_parent_write = false
end
Dirty-write warnings deduplicated by default
Dirty-write warnings now fire once per distinct set of unsaved fields (:once mode) instead of on every collection write. Creating one object with several collections previously emitted 7-10 identical warnings; it now emits one.
To restore the previous every-write behavior:
# Per class
class User < Familia::Horreum
dirty_write_warnings :warn
end
# Or globally
Familia.dirty_write_warnings = :warn
Unique-index storage format
unique_index hashkeys now store identifiers as raw strings instead of
JSON-encoded strings. Public reads are unchanged — find_by_*, get,
hgetall, and values all return the same identifier. Only the byte format
stored in Redis differs:
# Before: redis-cli HGET user:email_lookup alice@example.com
"\"u1\""
# After:
"u1"
unique_index declarations now back their lookup with a reference-type hashkey
(class: <indexed class>, reference: true), the same shape used by instances.
This lets each_record iterate the index and load records via load_multi:
# Before 2.10.0: raised Familia::Problem
# After: yields each indexed User
User.email_lookup.each_record { |user| user.notify! }
Backward compatibility: legacy JSON-encoded values are detected and stripped
automatically at read time, so lookups and each_record work without a rebuild.
A deprecation warning is emitted for each legacy value encountered, pointing to
this guide. The rebuild eliminates the warnings and the per-read detection
overhead.
Recommended: rebuild existing unique indexes once after upgrading:
# Class-level unique_index
User.rebuild_email_lookup
# Instance-scoped unique_index (within: Company) — rebuild per scope instance
company.rebuild_badge_index
Rebuilds read from the source of truth and rewrite the index via an atomic swap (no downtime). Alternatively, re-save the affected records if you rely only on auto-indexing.
2.10.1+ adds a project-wide sweep so you don't enumerate indexes by hand. It
detects which class-level unique indexes still hold legacy data and rebuilds
them, and provides a boot guard that fails before a stale index silently misses
a find_by_* lookup:
Familia.stale_indexes.each(&:rebuild!) # rebuild only what's stale
Familia.assert_indexes_current! # boot guard: raises if any index is stale
See Relationships → Introspection.
Encrypted-field AAD with transient fields
If you use aad_fields that reference a transient_field, the AAD output changes. Values encrypted by an earlier release were bound to "[REDACTED]" (the to_s of a RedactedString) and will no longer decrypt. Re-encrypt affected values after upgrading. This only affects transient fields used in aad_fields; regular fields are unchanged.
New Features
each_record on participation collections
Collections created by participates_in and class_participates_in now carry a
record_class: option pointing at the participant class. This lets each_record
iterate the collection and load participant records via load_multi:
class Domain < Familia::Horreum
feature :relationships
participates_in Customer, :domains, score: :created_at
end
# Before 2.10.0: raised Familia::Problem
# "each_record requires a DataType with a :record_class (or :class) option…"
# After: yields each loaded Domain
customer.domains.each_record { |domain| domain.refresh_dns! }
This replaces the manual workaround:
# No longer necessary
Domain.load_multi(customer.domains.to_a).compact.each { |d| ... }
No migration, and no behavior change. record_class: is a loading-only
hint: it tells each_record which class to hydrate, but does not change how
the collection serializes or deserializes. members, to_a, member?, and
score behave exactly as before (including JSON type-preservation for
numeric-looking identifiers and the issue #212 "use objects, not raw strings"
lookup rule). The stored bytes are unchanged. The only difference is that
each_record now works.
This is deliberately narrower than instances and unique_index, which use
class: + reference: true because they also want raw-string read semantics.
Participation only needs the loading capability, so it uses the record_class:
option and leaves read behavior untouched. If you want each_record on a
collection you declare by hand, add the same option:
sorted_set :domains, record_class: Domain.
One edge to know about: if you pre-declare the backing collection yourself
(e.g. sorted_set :domains on the target class) before participates_in runs,
your declaration wins and the collection is not given record_class:. Declare
it as sorted_set :domains, record_class: Domain if you want each_record on a
hand-declared participation collection.
Horreum.build factory block
Creates an instance and commits scalars + collection mutations in a single MULTI/EXEC:
user = User.build(email: "alice@example.com") do |u|
u.name = "Alice"
u..add("admin")
u.sessions.push("abc123")
end
# HMSET + SADD + RPUSH all fire in one MULTI/EXEC
build has create-only semantics — it raises RecordExistsError if the identifier already exists. Without a block, it degenerates to new(...).save.
atomic_write with WATCH support
atomic_write now supports optimistic locking via watch_keys: and pre_check::
user.atomic_write(watch_keys: [user.dbkey], pre_check: -> { user.exists? }) do
user.name = "Alice"
user..add("vip")
end
The pre_check callable runs between WATCH and MULTI. On WATCH abort, the method retries with exponential backoff.
Cross-model atomic_write
The module-level Familia.atomic_write(*instances, ...) persists several
(possibly different-class) Horreum instances in a single MULTI/EXEC. Once
the MULTI opens, every instance's dbclient resolves to the same transaction
connection, so each model's HMSET/EXPIRE/index HSET/instances ZADD queue
together and commit atomically:
Familia.atomic_write(customer, org) do
org.owner_id = customer.identifier
customer.orgs.add(org.identifier)
end
# customer's HMSET + org's HMSET + both collections' writes
# all land in ONE MULTI/EXEC
The optional block runs inside the transaction, before each instance is
persisted — the place to wire cross-references between instances and mutate
collections. Read-validate (prepare_for_save: timestamps + unique-index reads)
runs outside the MULTI; only the writes are atomic. The method returns
true on a clean commit and clears each instance's dirty state; on rollback it
returns false (or propagates the raised exception) and leaves dirty state
intact on every instance.
Constraint: all instances must share one logical database. MULTI/EXEC is
valid only within a single logical database, so the transaction is anchored on
instances.first.dbclient and a pre-flight guard rejects any set of roots (or
their related fields) that span databases:
class Account < Familia::Horreum; logical_database 0; end
class AuditEntry < Familia::Horreum; logical_database 5; end
Familia.atomic_write(account, audit_entry) { ... }
# raises Familia::CrossDatabaseError before any write —
# MULTI/EXEC cannot cross logical databases
For configurations that genuinely span databases, persist each model
separately (e.g. with save_with_collections).
Race-safe create-only across models
Combine watch_keys: with a pre_check: that rejects existing keys to get
create-only semantics spanning multiple models:
Familia.atomic_write(customer, org,
watch_keys: [customer.dbkey, org.dbkey],
pre_check: -> {
[customer, org].each { |r| raise Familia::RecordExistsError, r.dbkey if r.exists? }
}
) do
customer.name = 'Acme Owner'
org.owner_id = customer.identifier
end
A concurrent creation of either key during the WATCH window aborts the
whole MULTI and retries; on retry the existence check raises
RecordExistsError — no silent overwrite. If the watched keys keep changing and
retries are exhausted, Familia::OptimisticLockError is raised instead.
Redis Cluster note: a cross-model
MULTIadditionally requires the watched and written keys to share a hash slot, otherwise the server returns aCROSSSLOTerror. Co-locate related models with hash tags so they route to one slot, e.g.customer:{acct42}:objectandorg:{acct42}:object.
Per-class dirty-write warning controls
class SeedData < Familia::Horreum
dirty_write_warnings :off # suppress for known-safe bulk imports
end
class StrictModel < Familia::Horreum
dirty_write_warnings :strict # raise on any dirty collection write
end
Available modes: :strict, :warn, :once (default), :off.
Encryption key_material: option
Bind ciphertext to additional entropy beyond AAD:
class Secret < Familia::Horreum
encrypted_field :token, key_material: ->(rec) { rec.passphrase }
end
Wrong key_material derives a different key entirely (garbage output), unlike AAD which fails with an auth mismatch.
DatabaseLogger capture toggle
Disable in-memory command capture for production:
Familia::DatabaseLogger.capture_enabled = false
# Sampled log output continues; per-command buffer/timing cost drops to zero