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.



491
492
493
494
495
496
497
# File 'lib/familia/horreum/persistence.rb', line 491

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

#batch_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 batch_update (which does individual HSET per field) when writing several fields at once.

Examples:

Persist multiple fields atomically

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

Without updating expiration

user.batch_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:



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

def batch_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 :BATCH_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)

    self.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

#batch_update(**kwargs) ⇒ MultiResult

Updates multiple fields atomically in a Database transaction.

Examples:

Update multiple fields without affecting expiration

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

Update fields with expiration refresh

user.batch_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:



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

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

  guard_allowed_fields!(fields.keys)
  Familia.trace :BATCH_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

#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


579
580
581
582
# File 'lib/familia/horreum/persistence.rb', line 579

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:



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/familia/horreum/persistence.rb', line 305

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! unless result.nil?

  result
end

#dbclientObject



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

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:



527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/familia/horreum/persistence.rb', line 527

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

  # 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?
      Familia.trace :DELETE_RELATED_FIELDS!, nil,
                    "#{self.class} has relations: #{self.class.related_fields.keys}"

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

    # 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

#pipelinedObject



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

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:



641
642
643
644
# File 'lib/familia/horreum/persistence.rb', line 641

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:



605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/familia/horreum/persistence.rb', line 605

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:



696
697
698
699
700
701
# File 'lib/familia/horreum/persistence.rb', line 696

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

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to refresh key expiration (default: true)

Returns:

  • (Boolean)

    true on success

Raises:

See Also:



85
86
87
88
89
90
91
92
93
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
# File 'lib/familia/horreum/persistence.rb', line 85

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 => e
      Familia.error "Failed to serialize fields for logging",
        error: e.message,
        class: self.class.name,
        identifier: (identifier rescue nil)
      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! unless result.nil?

  # Return boolean indicating success
  !result.nil?
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)


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

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) unless result.nil?

  self
end

#save_if_not_existsBoolean

Non-raising variant of save_if_not_exists!

Returns:

  • (Boolean)

    true on success, false if object exists

Raises:



266
267
268
269
270
# File 'lib/familia/horreum/persistence.rb', line 266

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, it is not atomic. 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:



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

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

  attempts = 0
  begin
    attempts += 1

    result = watch do
      raise Familia::RecordExistsError, dbkey if exists?

      txn_result = transaction do |_multi|
        persist_to_storage(update_expiration)
      end

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

      txn_result
    end

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

    # Clear dirty tracking after successful save
    clear_dirty! unless result.nil?

    # Return boolean indicating success (consistent with save method)
    !result.nil?
  rescue OptimisticLockError => e
    Familia.debug "[save_if_not_exists]: OptimisticLockError (#{attempts}): #{e.message}"
    raise if attempts >= 3

    sleep(0.001 * (2**attempts))
    retry
  end
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:



178
179
180
181
182
# File 'lib/familia/horreum/persistence.rb', line 178

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:



668
669
670
671
672
673
# File 'lib/familia/horreum/persistence.rb', line 668

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



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

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