Module: Familia::Horreum::Persistence

Included in:
Familia::Horreum
Defined in:
lib/familia/horreum/persistence.rb

Overview

Serialization - Instance-level methods for object persistence and retrieval Handles conversion between Ruby objects and Valkey hash storage

Instance Method Summary collapse

Instance Method Details

#apply_fields(**fields) ⇒ self

Updates the object by applying multiple field values.

Sets multiple attributes on the object instance using their corresponding setter methods. Only fields that have defined setter methods will be updated.

Examples:

Update multiple fields on an object

user.apply_fields(name: "John", email: "john@example.com", age: 30)
# => #<User:0x007f8a1c8b0a28 @name="John", @email="john@example.com", @age=30>

Parameters:

  • fields (Hash)

    Hash of field names (as keys) and their values to apply to the object instance.

Returns:

  • (self)

    Returns the updated object instance for method chaining.



841
842
843
844
845
846
847
# File 'lib/familia/horreum/persistence.rb', line 841

def apply_fields(**fields)
  guard_allowed_fields!(fields.keys)
  fields.each do |field, value|
    send("#{field}=", value) if respond_to?("#{field}=")
  end
  self
end

#clear_fields!void

Note:

This operation does not persist the changes to the DB. Call save after clear_fields! if you want to persist the cleared state.

This method returns an undefined value.

Clears all fields by setting them to nil.

Resets all object fields to nil values, effectively clearing the object's state. This operation affects all fields defined on the object's class, setting each one to nil through their corresponding setter methods.

Examples:

Clear all fields on an object

user.name = "John"
user.email = "john@example.com"
user.clear_fields!
# => user.name and user.email are now nil


947
948
949
950
# File 'lib/familia/horreum/persistence.rb', line 947

def clear_fields!
  Familia.trace :CLEAR_FIELDS!, dbkey, self.class.uri
  self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
end

#commit_fields(update_expiration: true) ⇒ Object

Note:

On a failed claim the in-memory field keeps the conflicting value and stays dirty -- it was set by the caller before this call, so silently reverting it would destroy caller state. The object is diverged from storage until the caller corrects the value or calls refresh!. (multi_field_update differs: it applies the setters itself, so it rolls them back and never leaves the object diverged.)

Note:

The expiration update is only performed for classes that have the expiration feature enabled. For others, it's a no-op.

Note:

This method performs debug logging of the object's class, dbkey, and current state before committing to the DB.

Commits object fields to the DB storage.

Persists the current state of all object fields to the DB using HMSET. Optionally updates the key's expiration time if the feature is enabled for the object's class.

Unlike +save+, this method does not touch the created/updated timestamps. Class-level unique indexes are guarded and claimed before the transaction opens (see #prepare_for_partial_write) and re-affirmed inside it via #auto_update_class_indexes, so indexed lookups stay consistent with the stored hash (#308). It also updates the class-level +instances+ sorted set via +touch_instances!+, so the object will appear in +instances.to_a+ listings. Use this for updating fields on an object that is already persisted and tracked.

Examples:

Basic usage

user.name = "John"
user.email = "john@example.com"
result = user.commit_fields

Without updating expiration

result = user.commit_fields(update_expiration: false)

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the expiration time of the Valkey key. Defaults to true.

Returns:

  • (Object)

    The result of the HMSET operation from the DB.

Raises:

  • (Familia::RecordExistsError)

    if a class-level unique index already maps an indexed field's value to another record. Raised before the transaction opens; the object hash is untouched.

See Also:



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/familia/horreum/persistence.rb', line 429

def commit_fields(update_expiration: true)
  prepared_value = to_h_for_storage
  Familia.debug "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"

  # Guard and claim class-level unique indexes before the MULTI opens
  # (fail-closed: a constraint violation leaves the hash untouched).
  created_claims = prepare_for_partial_write

  begin
    result = transaction do |_conn|
      # Set all non-nil fields atomically
      hmset_result = hmset(prepared_value)

      # Remove any fields cleared to nil so their prior stored value is not
      # left stale (HMSET never deletes omitted fields).
      remove_stale_nil_fields

      # Maintain class-level indexes in the same transaction as the hash
      # write. Dirty tracking is still populated here (clear_dirty! runs
      # after EXEC), so changed indexed fields drop their stale entries.
      auto_update_class_indexes

      # Update expiration in same transaction to ensure atomicity
      self.update_expiration if hmset_result && update_expiration

      # Touch instances timeline so the object is visible to list-based
      # enumeration (instances.to_a, count, etc.). Skip it when nothing was
      # persisted and no hash key exists -- otherwise the identifier is
      # registered in `instances` pointing at a missing hash (see
      # {#persist_to_storage}).
      touch_instances! if hmset_result && !prepared_value.empty?

      hmset_result
    end
  rescue StandardError
    # A raise here means the MULTI never reached EXEC (redis-rb discards
    # on a raised block), so the hash write demonstrably did not land
    # and the pre-MULTI claim entries are the only Redis-side trace of
    # this write. Release them so the index cannot point at a hash
    # state that was never stored.
    release_created_index_claims!(created_claims)
    raise
  end

  if persisted_successfully?(result)
    # Clear dirty tracking after successful commit
    clear_dirty!
  else
    reconcile_index_claims_after_failed_write(created_claims, result)
  end

  result
end

#dbclientObject



1074
# File 'lib/familia/horreum/persistence.rb', line 1074

def dbclient(...) = self.class.dbclient(...)

#destroy!void

Note:

This method provides high-level object lifecycle management. It operates at the object level for ORM-style operations, while delete! operates directly on database keys. Use destroy! when removing complete objects from the system.

Note:

When debugging is enabled, this method will trace the deletion operation for diagnostic purposes.

This method returns an undefined value.

Permanently removes this object and its related fields from the DB.

Deletes the object's database key, all related fields (lists, sets, hashes, etc.), and removes the identifier from the class-level +instances+ sorted set. This operation is irreversible.

This is the instance-level counterpart to the class method of the same name. Both clean up related fields and the main hash key, but only this instance method removes from +instances+. See the class method's documentation for that known gap.

Examples:

Remove a user object from storage

user = User.new(id: 123)
user.destroy!
# Object is now permanently removed from the DB

See Also:



877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
# File 'lib/familia/horreum/persistence.rb', line 877

def destroy!
  Familia.trace :DESTROY!, dbkey, self.class.uri if Familia.debug?

  # Pre-read instance-scoped index tracker before MULTI/EXEC
  # (HGETALL returns futures inside a transaction, not values).
  # Maps "<scope_config>\t<index_name>\t<scope_id>" => indexed value.
  tracked_scopes = if respond_to?(:read_instance_index_scopes)
    read_instance_index_scopes
  else
    {}
  end

  result = transaction do |_conn|
    delete!

    if self.class.relations?
      if Familia.debug?
        Familia.trace :DELETE_RELATED_FIELDS!, nil,
                      "#{self.class} has relations: #{self.class.related_fields.keys}"
      end

      self.class.related_fields.each_key do |name|
        obj = send(name)
        if Familia.debug?
          Familia.trace :DELETE_RELATED_FIELD, name, "Deleting related field #{name} (#{obj.dbkey})"
        end
        obj.delete!
      end
    end

    # Clean up instance-scoped index entries (#282) using
    # pre-read tracker data. Must precede class-level cleanup.
    if !tracked_scopes.empty? && respond_to?(:remove_tracked_index_entries!)
      remove_tracked_index_entries!(tracked_scopes)
    end

    # Clean up class-level index entries (#241)
    remove_from_class_indexes!

    remove_from_instances!
  end

  # Structured lifecycle logging and instrumentation
  Familia.debug 'Horreum destroyed',
    class: self.class.name,
    identifier: identifier,
    key: dbkey

  Familia::Instrumentation.notify_lifecycle(:destroy, self, key: dbkey)

  result
end

#multi_field_fast_write(**kwargs) ⇒ self

Atomically writes multiple fields to the database using a single HMSET.

This is the multi-field equivalent of the fast_writer (!) methods. It sets all instance variables, serializes the values, and persists them in one HMSET command within a transaction. More efficient than multi_field_update (which does individual HSET per field) when writing several fields at once.

Values bypass the field setters on the write path, so field-type semantics are enforced up front: transient fields are rejected (they are never persisted), and encrypted fields only accept nil (deletes the field) or the ConcealedString returned by the field getter -- raw plaintext never reaches the database. To encrypt a new plaintext value, use the field setter followed by save or save_fields.

Fields backing a class-level index (unique_index or multi_index) are refused outright: the one-HMSET contract leaves no room for the out-of-transaction claim that index maintenance requires (ADR-0002), and writing the hash without the index would leave lookups stale (#308). Use multi_field_update or save for indexed fields. The check is local metadata only -- no extra database round trips.

Examples:

Persist multiple fields atomically

user.multi_field_fast_write(name: "Jane", email: "jane@example.com")

Without updating expiration

user.multi_field_fast_write(status: "active", update_expiration: false)

Parameters:

  • kwargs (Hash)

    Field names and values to write. Special key :update_expiration controls whether to refresh key expiration (default: true).

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    if a field is undeclared, transient, or an encrypted field given a value that is not nil or a ConcealedString

  • (Familia::IndexedFieldFastWriteError)

    if a field backs a class-level index. Raised before any write; the hash is untouched.

See Also:



689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
# File 'lib/familia/horreum/persistence.rb', line 689

def multi_field_fast_write(**kwargs)
  update_exp = kwargs.delete(:update_expiration) { true }
  fields = kwargs

  raise ArgumentError, 'No fields specified' if fields.empty?

  guard_persistable_fields!(fields)
  guard_unindexed_fields!(fields.keys)

  Familia.trace :MULTI_FIELD_FAST_WRITE, nil, fields.keys if Familia.debug?

  # Serialize values before the transaction (read-only on instance).
  # A nil value deletes the field rather than storing "null" (see
  # Serialization#to_h_for_storage), so split writes from removals.
  serialized = {}
  nil_fields = []
  fields.each do |field, value|
    if value.nil?
      nil_fields << field.to_s
    else
      serialized[field] = serialize_value(value)
    end
  end

  result = transaction do |_conn|
    hmset(serialized)

    dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?

    update_expiration if update_exp

    touch_instances!
  end

  # Update in-memory state only after transaction succeeds,
  # so a failed transaction never leaves the object diverged.
  if result.is_a?(MultiResult) && result.successful?
    fields.each do |field, value|
      send(:"#{field}=", value) if respond_to?(:"#{field}=")
    end
    clear_dirty!(*fields.keys)
  end

  self
end

#multi_field_update(**kwargs) ⇒ MultiResult

Updates multiple fields atomically in a Database transaction.

Values bypass the field setters on the write path, so field-type semantics are enforced up front: transient fields are rejected (they are never persisted), and encrypted fields only accept nil (deletes the field) or the ConcealedString returned by the field getter -- raw plaintext never reaches the database. To encrypt a new plaintext value, use the field setter followed by save or save_fields.

Class-level indexes on the written fields are maintained (#308): the in-memory setters are applied before the transaction so dirty tracking captures the old values, unique indexes are guarded and claimed before the MULTI opens (see #prepare_for_partial_write), and the index entries are updated inside the same transaction as the hash write. On a failed claim or transaction the in-memory state is rolled back to its pre-call values, so a failed update never leaves the object diverged from storage.

Examples:

Update multiple fields without affecting expiration

.multi_field_update(viewed: 1, updated: Familia.now.to_i, update_expiration: false)

Update fields with expiration refresh

user.multi_field_update(name: "John", email: "john@example.com")

Parameters:

  • kwargs (Hash)

    Field names and values to update. Special key :update_expiration controls whether to update key expiration (default: true)

Returns:

Raises:

  • (ArgumentError)

    if a field is undeclared, transient, an encrypted field given a value that is not nil or a ConcealedString, or an objid/extid field (their setters carry lookup-hash side effects the rollback cannot undo -- see

    guard_rollback_safe_fields!)

  • (Familia::RecordExistsError)

    if a class-level unique index already maps a written field's value to another record. Raised before the transaction opens; the object hash is untouched.



570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/familia/horreum/persistence.rb', line 570

def multi_field_update(**kwargs)
  update_expiration = kwargs.delete(:update_expiration) { true }
  fields = kwargs

  guard_persistable_fields!(fields)
  guard_rollback_safe_fields!(fields.keys)
  Familia.trace :MULTI_FIELD_UPDATE, nil, fields.keys if Familia.debug?

  # Apply the setters BEFORE the claim and the transaction, so dirty
  # tracking captures the old values (auto_update_class_indexes reads
  # them to drop stale index entries) and the generated claim/update
  # index methods -- which read the field getters -- see the values
  # being written. The snapshot below restores the pre-call in-memory
  # state (ivar value and dirty status) if anything fails, preserving
  # the documented never-diverged rollback semantics.
  rollback = capture_field_rollback_state(fields.keys)
  created_claims = []

  begin
    fields.each do |field, value|
      send(:"#{field}=", value) if respond_to?(:"#{field}=")
    end

    # Guard and claim class-level unique indexes before the MULTI opens
    # (fail-closed: a constraint violation leaves the hash untouched).
    # A conflict inside the claim releases its own partial progress, so
    # created_claims stays empty on that path.
    created_claims = prepare_for_partial_write(fields.keys)

    result = transaction do |_conn|
      # 1. Update all fields atomically. A nil value deletes the field
      # rather than storing "null", so absence stays authoritative (see
      # Serialization#to_h_for_storage).
      fields.each do |field, value|
        if value.nil?
          remove_field(field)
        else
          hset field, serialize_value(value)
        end
      end

      # 2. Maintain class-level indexes on the written fields in the
      # same transaction as the hash write.
      auto_update_class_indexes(only: fields.keys)

      # 3. Update expiration in same transaction
      self.update_expiration if update_expiration

      # 4. Register in instances sorted set so the object is visible
      # to list-based enumeration (instances.to_a, count, etc.)
      touch_instances!
    end
  rescue StandardError
    # Release BEFORE restoring: the MULTI never reached EXEC (redis-rb
    # discards on a raised block), so the claim entries are the only
    # Redis-side trace of this write -- and release_unique_*! reads the
    # CURRENT field value, which must still be the claimed one here
    # (see #release_created_index_claims!).
    release_created_index_claims!(created_claims)
    restore_field_rollback_state(rollback)
    raise
  end

  if result.is_a?(MultiResult) && result.successful?
    clear_dirty!(*fields.keys)
  else
    # Same ordering as the rescue above: when the claims are released
    # (nil/aborted result), the fields must still hold the claimed
    # values. In the ambiguous executed-with-errors case the claims are
    # kept (see #reconcile_index_claims_after_failed_write) while the
    # in-memory state is still rolled back -- the warn it emits is the
    # record of that divergence.
    reconcile_index_claims_after_failed_write(created_claims, result)
    restore_field_rollback_state(rollback)
  end

  result
end

#pipelinedObject



1073
# File 'lib/familia/horreum/persistence.rb', line 1073

def pipelined(...) = self.class.pipelined(...)

#refreshself

Refreshes object state from the DB and returns self for method chaining.

Loads the current state of the object from the DB storage, updating all field values to match their persisted state. This method provides a chainable interface to the refresh! operation.

Examples:

Refresh and chain operations

user.refresh.save
user.refresh.apply_fields(status: 'active')

Returns:

  • (self)

    The refreshed object instance, enabling method chaining

Raises:

See Also:



1009
1010
1011
1012
# File 'lib/familia/horreum/persistence.rb', line 1009

def refresh
  refresh!
  self
end

#refresh!void

Note:

This method discards any unsaved changes to the object. Use with caution when the object has been modified but not yet persisted.

Note:

Transient fields are reset to nil during refresh since they have no authoritative source in Valkey storage.

This method returns an undefined value.

Refreshes the object state from the DB storage.

Reloads all persistent field values from the DB, overwriting any unsaved changes in the current object instance. This operation synchronizes the object with its stored state in the database.

Examples:

Refresh object from the DB

user.name = "Changed Name"  # unsaved change
user.refresh!
# => user.name is now the value from the DB storage

Raises:



973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
# File 'lib/familia/horreum/persistence.rb', line 973

def refresh!
  Familia.trace :REFRESH, nil, self.class.uri if Familia.debug?
  raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)

  fields = hgetall
  Familia.debug "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"

  # Reset transient fields to nil for semantic clarity and ORM consistency
  # Transient fields have no authoritative source, so they should return to
  # their uninitialized state during refresh operations
  reset_transient_fields!

  result = naive_refresh(**fields)

  # Clear dirty tracking since object now matches DB state
  clear_dirty!

  result
end

#remove_from_instances!Object

Removes this object from the class-level instances sorted set.

Symmetric counterpart to #touch_instances!. After calling this method the object will no longer appear in +instances.to_a+ listings or be counted by +instances.count+. The underlying database hash key is NOT deleted -- use #destroy! for full removal.

Safe to call inside MULTI/EXEC transactions (no read-before-write).

Examples:

Remove from instances without deleting data

user.remove_from_instances!  # no longer in User.instances
user.exists?                 # => true (hash key still present)

Returns:

  • (Object)

    The return value of the ZREM command (integer or Redis::Future inside a transaction)

Raises:

See Also:



1064
1065
1066
1067
1068
1069
# File 'lib/familia/horreum/persistence.rb', line 1064

def remove_from_instances!
  ident = identifier
  raise Familia::NoIdentifier, "No identifier for #{self.class}" if ident.nil? || ident.to_s.empty?

  self.class.instances.remove(ident)
end

#save(update_expiration: true) ⇒ Boolean

Note:

This is a FULL-OVERWRITE of the object's scalar state: afterwards the stored hash matches the in-memory object exactly. Non-nil fields are written and fields that are nil in memory are removed from storage. A field managed out of band -- e.g. one claimed by another actor via HSETNX while this (possibly stale) copy still holds nil for it -- is therefore cleared by a full save. To update an object without disturbing such fields, use the targeted writers (#save_fields, #multi_field_update, #multi_field_fast_write) or #refresh! first.

Persists object state to storage with timestamps, validation, and indexing.

Performs a complete save operation in an atomic transaction:

  • Sets created/updated timestamps
  • Validates unique index constraints
  • Persists all fields
  • Updates expiration (optional)
  • Updates class-level indexes
  • Adds to instances collection

Transaction Safety

This method CANNOT be called within a transaction context. The save process requires reading current state to validate unique constraints, which would return uninspectable Redis::Future objects inside transactions.

Correct Pattern:

customer = Customer.new(email: 'test@example.com')
customer.save  # Validates unique constraints here

customer.transaction do
  # Perform other atomic operations
  customer.increment(:login_count)
  customer.hset(:last_login, Familia.now.to_i)
end

Incorrect Pattern:

Customer.transaction do
  customer = Customer.new(email: 'test@example.com')
  customer.save  # Raises Familia::OperationModeError
end

Examples:

Basic usage

user = User.new(email: "john@example.com")
user.save  # => true

Post-save callback (idiomatic Ruby)

if user.save
  AuditLog.record(:user_updated, user.identifier)
  notify(user)
end

Single-expression short-circuit

user.save && AuditLog.record(:user_updated, user.identifier)

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to refresh key expiration (default: true)

Returns:

  • (Boolean)

    true on success

Raises:

See Also:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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
# File 'lib/familia/horreum/persistence.rb', line 103

def save(update_expiration: true)
  start_time = Familia.now_in_μs if Familia.debug?

  # Prevent save within transaction - unique index guards require read operations
  # which are not available in Redis MULTI/EXEC blocks
  if Fiber[:familia_transaction]
    raise Familia::OperationModeError, <<~ERROR_MESSAGE
      Cannot call save within a transaction. Save operations must be called outside transactions to ensure unique constraints can be validated.
    ERROR_MESSAGE
  end

  Familia.trace :SAVE, nil, self.class.uri if Familia.debug?

  # Prepare object for persistence (timestamps, validation). Everything
  # from here to EXEC runs with the unique-index claims already written
  # server-side (ADR-0002), so any failure before the transaction lands
  # -- including guard_tracked_index_scopes! raising below -- must
  # release the entries this call created; hence the rescue around the
  # whole post-claim region.
  created_claims = prepare_for_save

  begin
    # Pre-read the instance-scoped index tracker before MULTI/EXEC, for
    # the same reason destroy! does: HGETALL inside a transaction returns
    # futures, not values. Replayed inside the transaction below, where
    # dirty tracking is still live (see #auto_update_class_indexes).
    tracked_scopes = if respond_to?(:read_instance_index_scopes)
      read_instance_index_scopes
    else
      {}
    end

    # A non-empty tracker with no object hash can only belong to a dead
    # incarnation of this identifier: add_to_* refuses never-saved
    # records, so entries outliving the hash mean delete! (or expiry)
    # removed the hash out from under them. Replaying them would silently
    # re-join whatever scopes the PREVIOUS record occupied (#365), so the
    # transaction below prunes them instead -- replaying remove_from_*
    # with the recorded values, which also clears the index entries the
    # dead incarnation left behind. The EXISTS probe costs a round trip
    # only when the tracker has entries.
    #
    # The probe runs before the MULTI, so a concurrent delete! landing
    # between the two makes a live tracker look stale and the save prunes
    # memberships that were valid an instant earlier. That is the
    # accepted conservative failure mode -- losing memberships for a
    # record that was just deleted beats joining the wrong scopes -- and
    # the same read-window race save already accepts for its guards
    # (MULTI-only by design, no WATCH).
    stale_tracker = !tracked_scopes.empty? && !exists?

    # Validate instance-scoped unique constraints here too -- same reason
    # prepare_for_save runs guard_unique_indexes! outside the transaction
    # (the guard reads). guard_unique_indexes! only covers class-level
    # relationships; without this, the refresh below could evict another
    # record's index entry silently. Stale entries are pruned rather than
    # replayed, so there is no membership to validate.
    guard_tracked_index_scopes!(tracked_scopes) if !stale_tracker && respond_to?(:guard_tracked_index_scopes!)

    # Everything in ONE transaction for complete atomicity
    result = transaction do |_conn|
      persist_to_storage(update_expiration, tracked_index_scopes: tracked_scopes,
                                            prune_stale_tracker: stale_tracker)
    end
  rescue StandardError
    # EXEC never ran (a raised transaction block is discarded), so the
    # hash write demonstrably did not land and the claim entries are
    # the only Redis-side trace of this save.
    release_created_index_claims!(created_claims)
    raise
  end

  # Structured lifecycle logging and instrumentation
  if Familia.debug? && start_time
    duration = Familia.now_in_μs - start_time

    begin
      fields_count = to_h_for_storage.size
    rescue StandardError => e
      Familia.error 'Failed to serialize fields for logging',
        error: e.message,
        class: self.class.name,
        identifier: begin
          identifier
        rescue StandardError
          nil
        end
      fields_count = 0
    end

    Familia.debug 'Horreum saved',
      class: self.class.name,
      identifier: identifier,
      duration: duration,
      fields_count: fields_count,
      update_expiration: update_expiration

    Familia::Instrumentation.notify_lifecycle(:save, self,
      duration: duration,
      update_expiration: update_expiration,
      fields_count: fields_count)
  end

  # Clear dirty tracking after successful save; otherwise decide the
  # fate of the freshly-created index claims (release on abort, keep
  # and warn when EXEC ran -- see the helper for the reasoning).
  if persisted_successfully?(result)
    clear_dirty!
  else
    reconcile_index_claims_after_failed_write(created_claims, result)
  end

  # Return boolean indicating success
  persisted_successfully?(result)
end

#save_fields(*field_names, update_expiration: true) ⇒ self

Note:

On a failed claim the in-memory field keeps the conflicting value and stays dirty -- it was set by the caller before this call, so silently reverting it would destroy caller state. The object is diverged from storage until the caller corrects the value or calls refresh!. (multi_field_update differs: it applies the setters itself, so it rolls them back and never leaves the object diverged.)

Persists only the specified fields to Redis.

Saves the current in-memory values of specified fields to Redis without modifying them first. Fields must already be set on the instance.

Class-level indexes on the written fields are maintained (#308): unique indexes are guarded and claimed before the transaction opens (see #prepare_for_partial_write), and the index entries are updated inside the same transaction as the hash write. Indexes on fields not named here are left untouched.

Examples:

Persist only passphrase fields after updating them

customer.update_passphrase('secret').save_fields(:passphrase, :passphrase_encryption)

Parameters:

  • field_names (Array<Symbol, String>)

    Names of fields to persist

  • update_expiration (Boolean) (defaults to: true)

    Whether to refresh key expiration

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (Familia::RecordExistsError)

    if a class-level unique index already maps a written field's value to another record. Raised before the transaction opens; the object hash is untouched.



763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
# File 'lib/familia/horreum/persistence.rb', line 763

def save_fields(*field_names, update_expiration: true)
  raise ArgumentError, 'No fields specified' if field_names.empty?

  Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?

  # Build hash of non-nil field values; collect nil'd fields for removal.
  # A nil field is deleted rather than stored as "null" so that absence
  # stays authoritative (see Serialization#to_h_for_storage). Built
  # before the transaction so an unknown field fails before any claim
  # or write happens.
  fields_hash = {}
  nil_fields = []
  field_names.each do |field|
    field_sym = field.to_sym
    raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)

    value = send(field_sym)
    if value.nil?
      nil_fields << field.to_s
    else
      fields_hash[field] = serialize_value(value)
    end
  end

  # Guard and claim class-level unique indexes before the MULTI opens
  # (fail-closed: a constraint violation leaves the hash untouched).
  created_claims = prepare_for_partial_write(field_names)

  begin
    result = transaction do |_conn|
      # Set all non-nil fields at once (hmset no-ops on an empty hash)
      hmset(fields_hash)

      # Remove any nil'd fields so their prior stored value does not linger
      dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?

      # Maintain class-level indexes on the written fields in the same
      # transaction as the hash write
      auto_update_class_indexes(only: field_names)

      # Update expiration in same transaction
      self.update_expiration if update_expiration

      # Touch instances timeline so the object is visible
      # to list-based enumeration (instances.to_a, count, etc.)
      touch_instances!
    end
  rescue StandardError
    # The MULTI never reached EXEC (redis-rb discards on a raised
    # block): the hash write demonstrably did not land, so the
    # pre-MULTI claim entries must not survive it.
    release_created_index_claims!(created_claims)
    raise
  end

  if persisted_successfully?(result)
    clear_dirty!(*field_names)
  else
    reconcile_index_claims_after_failed_write(created_claims, result)
  end

  self
end

#save_if_not_existsBoolean

Non-raising variant of save_if_not_exists!

Returns:

  • (Boolean)

    true on success, false if object exists

Raises:



377
378
379
380
381
# File 'lib/familia/horreum/persistence.rb', line 377

def save_if_not_exists(...)
  save_if_not_exists!(...)
rescue RecordExistsError
  false
end

#save_if_not_exists!(update_expiration: true) ⇒ Boolean

♀︎ Additional note about WATCH + MULTI/EXEC in Valkey/Redis or any two step existence check in any database: although it is more cautious and, on a single connection, a genuine optimistic lock (a concurrent write to the watched key aborts EXEC), it is still not a server-side atomic check. The only way to do that is if the database process can determine itself whether the record already exists or not. For Valkey/Redis, that means writing the lua to do that.

Examples:

user = User.new(id: 123)
user.save_if_not_exists!  # => true or raises

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to refresh key expiration (default: true)

Returns:

  • (Boolean)

    true on successful save

Raises:



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
# File 'lib/familia/horreum/persistence.rb', line 300

def save_if_not_exists!(update_expiration: true)
  # Prevent save_if_not_exists! within transaction - needs to read existence state
  if Fiber[:familia_transaction]
    raise Familia::OperationModeError, <<~ERROR_MESSAGE
      Cannot call save_if_not_exists! within a transaction. This method
      must be called outside transactions to properly check existence.
    ERROR_MESSAGE
  end

  identifier_field = self.class.identifier_field

  Familia.debug "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
  Familia.trace :SAVE_IF_NOT_EXISTS, nil, self.class.uri if Familia.debug?

  # Prepare object for persistence (timestamps, validation). As in
  # save, the unique-index claims are written server-side here, so a
  # failure that prevents the hash write from landing (record exists,
  # retries exhausted) must release the entries this call created.
  created_claims = prepare_for_save

  # Drive WATCH + MULTI/EXEC through a SINGLE resolved connection so the
  # optimistic lock is effective (the primitive owns abort detection and
  # retry). The existence check runs in the WATCH window: if the key is
  # created between WATCH and EXEC, Redis aborts and the primitive retries.
  begin
    result = Familia::Connection::TransactionCore.execute_watched_transaction(
      -> { dbclient }, watch_keys: [dbkey]
    ) do |conn|
      raise Familia::RecordExistsError, dbkey if exists?

      # Snapshot the instance-scoped index tracker in the WATCH window,
      # alongside the existence check and for the same reason: it is a
      # read, and reads inside the MULTI below return futures. Read here
      # rather than before the watched block so a WATCH abort re-reads it
      # on retry. Usually empty (this path only proceeds when the object
      # hash is absent), but a hash removed out of band -- delete!, or
      # expiry -- can leave entries behind. Those entries describe the
      # previous incarnation of this identifier, not the record being
      # created, so the transaction prunes them (#365): remove_from_* is
      # replayed with the recorded values and the tracker cleared. No
      # uniqueness guard is needed -- nothing is being joined.
      tracked_scopes = if respond_to?(:read_instance_index_scopes)
        read_instance_index_scopes
      else
        {}
      end

      Familia::Connection::TransactionCore.execute_normal_transaction(-> { conn }) do |_m|
        persist_to_storage(update_expiration, tracked_index_scopes: tracked_scopes,
                                              prune_stale_tracker: true)
      end
    end
  rescue StandardError
    # The record exists, or the WATCH retries were exhausted: either
    # way the hash write did not land, so the claims this call created
    # must not survive it.
    release_created_index_claims!(created_claims)
    raise
  end

  Familia.debug "[save_if_not_exists]: result=#{result.inspect}"

  if persisted_successfully?(result)
    # Clear dirty tracking after successful save
    clear_dirty!
  else
    reconcile_index_claims_after_failed_write(created_claims, result)
  end

  # Return boolean indicating success (consistent with save method)
  persisted_successfully?(result)
end

#save_with_collections(update_expiration: true) { ... } ⇒ Boolean

Saves scalar fields first, then executes collection operations in the block.

This method enforces the ordering invariant that scalar fields (stored in the object's hash key via HMSET) are committed before any collection operations (SADD, ZADD, RPUSH, etc.) run. If +save+ raises, the block is never executed, preventing orphaned collection data.

Because scalar fields and collection fields typically live on different Redis keys, they cannot share a single MULTI/EXEC transaction. This method provides a safe sequential alternative: scalars commit first, then collections execute. If a collection operation fails after save succeeds, the scalar data remains persisted (no automatic rollback of the save).

Examples:

Save a plan then update its feature set

plan.name = 'Premium'
plan.save_with_collections do
  plan.features.clear
  plan.features.add('premium')
  plan.features.add('priority_support')
end

Block is skipped when save fails

plan.save_with_collections do
  plan.features.add('premium')  # never runs if save raises
end

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Passed through to +save+ (default: true)

Yields:

  • Block containing collection operations to execute after save

Returns:

  • (Boolean)

    true if save succeeded and block completed

Raises:

See Also:



258
259
260
261
262
# File 'lib/familia/horreum/persistence.rb', line 258

def save_with_collections(update_expiration: true)
  saved = save(update_expiration: update_expiration)
  yield if saved && block_given?
  saved
end

#touch_instances!Object

Updates this object's timestamp in the class-level instances sorted set.

The instances sorted set is a timeline of last-modified times, not a registry. This method performs a ZADD with the current timestamp as score: if the identifier is already present the score is updated; if absent, it is added. No preliminary member? check is performed, making this safe to call inside MULTI/EXEC transactions where read operations return uninspectable Future objects.

Examples:

Touch after commit_fields

user.commit_fields
user.touch_instances!  # now visible in User.instances

Safe to call multiple times (updates timestamp)

user.touch_instances!
user.touch_instances!  # score updated, no duplicate

Returns:

  • (Object)

    The return value of the ZADD command (boolean or Redis::Future inside a transaction)

Raises:



1036
1037
1038
1039
1040
1041
# File 'lib/familia/horreum/persistence.rb', line 1036

def touch_instances!
  ident = identifier
  raise Familia::NoIdentifier, "No identifier for #{self.class}" if ident.nil? || ident.to_s.empty?

  self.class.instances.add(self, Familia.now)
end

#transactionObject

Convenience methods that forward to the class method of the same name



1072
# File 'lib/familia/horreum/persistence.rb', line 1072

def transaction(...) = self.class.transaction(...)