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.



522
523
524
525
526
527
528
# File 'lib/familia/horreum/persistence.rb', line 522

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


619
620
621
622
# File 'lib/familia/horreum/persistence.rb', line 619

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:

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 run +prepare_for_save+ (timestamps, unique index guards) and does not update class indexes. It does update 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.

See Also:



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/familia/horreum/persistence.rb', line 317

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})"

  result = transaction do |_conn|
    # Set all fields atomically
    result = hmset(prepared_value)

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

    # Touch instances timeline so the object is visible
    # to list-based enumeration (instances.to_a, count, etc.)
    touch_instances! if result

    result
  end

  # Clear dirty tracking after successful commit
  clear_dirty! if persisted_successfully?(result)

  result
end

#dbclientObject



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

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:



558
559
560
561
562
563
564
565
566
567
568
569
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
# File 'lib/familia/horreum/persistence.rb', line 558

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

  # Execute all deletion operations within a transaction
  result = transaction do |_conn|
    # Delete the main object key
    delete!

    # Delete all related fields if present
    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 class-level index entries (see issue #241).
    # Instance-scoped indexes require a scope instance unavailable here;
    # tracked separately in issue #244.
    remove_from_class_indexes!

    # Remove from instances collection
    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.

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)

See Also:



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

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

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

  guard_allowed_fields!(fields.keys)

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

  # Serialize values before the transaction (read-only on instance)
  serialized = {}
  fields.each do |field, value|
    serialized[field] = serialize_value(value)
  end

  result = transaction do |_conn|
    hmset(serialized)

    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.

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:



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

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

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

  result = transaction do |_conn|
    # 1. Update all fields atomically (Redis only, no in-memory mutation)
    fields.each do |field, value|
      prepared_value = serialize_value(value)
      hset field, prepared_value
    end

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

    # 3. Register in instances sorted set so the object is visible
    # to list-based enumeration (instances.to_a, count, etc.)
    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

  result
end

#pipelinedObject



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

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:



681
682
683
684
# File 'lib/familia/horreum/persistence.rb', line 681

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:



645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
# File 'lib/familia/horreum/persistence.rb', line 645

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:



736
737
738
739
740
741
# File 'lib/familia/horreum/persistence.rb', line 736

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

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:



94
95
96
97
98
99
100
101
102
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
# File 'lib/familia/horreum/persistence.rb', line 94

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)
  prepare_for_save

  # Everything in ONE transaction for complete atomicity
  result = transaction do |_conn|
    persist_to_storage(update_expiration)
  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
  clear_dirty! if persisted_successfully?(result)

  # Return boolean indicating success
  persisted_successfully?(result)
end

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

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.

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:

  • (ArgumentError)


475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'lib/familia/horreum/persistence.rb', line 475

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?

  result = transaction do |_conn|
    # Build hash of field names to serialized values
    fields_hash = {}
    field_names.each do |field|
      field_sym = field.to_sym
      raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)

      value = send(field_sym)
      prepared_value = serialize_value(value)
      fields_hash[field] = prepared_value
    end

    # Set all fields at once using hmset
    hmset(fields_hash)

    # 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

  clear_dirty!(*field_names) if persisted_successfully?(result)

  self
end

#save_if_not_existsBoolean

Non-raising variant of save_if_not_exists!

Returns:

  • (Boolean)

    true on success, false if object exists

Raises:



278
279
280
281
282
# File 'lib/familia/horreum/persistence.rb', line 278

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:



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

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)
  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.
  result = Familia::Connection::TransactionCore.execute_watched_transaction(
    -> { dbclient }, watch_keys: [dbkey]
  ) do |conn|
    raise Familia::RecordExistsError, dbkey if exists?

    Familia::Connection::TransactionCore.execute_normal_transaction(-> { conn }) do |_m|
      persist_to_storage(update_expiration)
    end
  end

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

  # Clear dirty tracking after successful save
  clear_dirty! if persisted_successfully?(result)

  # 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:



192
193
194
195
196
# File 'lib/familia/horreum/persistence.rb', line 192

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:



708
709
710
711
712
713
# File 'lib/familia/horreum/persistence.rb', line 708

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



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

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