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.



334
335
336
337
338
339
340
# File 'lib/familia/horreum/persistence.rb', line 334

def apply_fields(**fields)
  fields.each do |field, value|
    # Apply the field value if the setter method exists
    send("#{field}=", value) if respond_to?("#{field}=")
  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: Time.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:



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/familia/horreum/persistence.rb', line 261

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

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

  transaction do |_conn|
    # 1. Update all fields atomically
    fields.each do |field, value|
      prepared_value = serialize_value(value)
      hset field, prepared_value
      # Update instance variable to keep object in sync
      send("#{field}=", value) if respond_to?("#{field}=")
    end

    # 2. Update expiration in same transaction
    self.update_expiration if update_expiration
  end
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


405
406
407
408
# File 'lib/familia/horreum/persistence.rb', line 405

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.

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.



234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/familia/horreum/persistence.rb', line 234

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

  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

    result
  end
end

#dbclientObject



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

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 and all associated data. This operation is irreversible and will permanently destroy all stored information for this object instance and the additional list, set, hash, string etc fields defined for this class.

Examples:

Remove a user object from storage

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

See Also:



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/familia/horreum/persistence.rb', line 366

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

  # Execute all deletion operations within a transaction
  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
  end
end

#pipelinedObject



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

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:



462
463
464
465
# File 'lib/familia/horreum/persistence.rb', line 462

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:



431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/familia/horreum/persistence.rb', line 431

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

  fields = hgetall
  Familia.ld "[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!

  naive_refresh(**fields)
end

#save(update_expiration: true) ⇒ Boolean

Note:

Cannot be called within a transaction. Call save first to start the transaction, or use commit_fields/hmset for manual field updates within transactions.

Note:

When Familia.debug? is enabled, this method will trace the save operation for debugging purposes.

Persists the object to Valkey storage with automatic timestamping and validation.

Saves the current object state to Valkey storage, automatically setting created and updated timestamps if the object supports them. The method validates unique indexes before the transaction, commits all persistent fields, and optionally updates the key's expiration.

Examples:

Save an object to Valkey

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

Save without updating expiration

user.save(update_expiration: false)
# => true

Handle duplicate unique index

user2 = User.new(name: "Jane", email: "john@example.com")
user2.save
# => raises Familia::RecordExistsError

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the key's expiration time after saving. Defaults to true.

Returns:

  • (Boolean)

    true if the save operation was successful, false otherwise.

Raises:

  • (Familia::OperationModeError)

    If called within an existing transaction. Guards need to read current values, which is not possible inside MULTI/EXEC.

  • (Familia::RecordExistsError)

    If a unique index constraint is violated for any class-level unique_index relationships.

See Also:



79
80
81
82
83
84
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
# File 'lib/familia/horreum/persistence.rb', line 79

def save(update_expiration: true)
  # 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,
      "Cannot call save within a transaction. Save operations must be called outside transactions to ensure unique constraints can be validated."
  end

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

  # Update timestamp fields before saving
  self.created ||= Familia.now if respond_to?(:created)
  self.updated = Familia.now if respond_to?(:updated)

  # Validate unique indexes BEFORE the transaction
  guard_unique_indexes!

  # Everything in ONE transaction for complete atomicity
  result = transaction do |_conn|
    # 1. Save all fields
    prepared_h = to_h_for_storage
    hmset_result = hmset(prepared_h)

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

    # 3. Update class-level indexes
    auto_update_class_indexes

    # 4. Add to instances collection if available
    self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)

    hmset_result
  end

  Familia.ld "[save] #{self.class} #{dbkey} #{result} (update_expiration: #{update_expiration})"

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


293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/familia/horreum/persistence.rb', line 293

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?

  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
  end

  self
end

#save_if_not_existsObject



203
204
205
206
207
# File 'lib/familia/horreum/persistence.rb', line 203

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

#save_if_not_exists!(update_expiration: true) ⇒ Boolean

Note:

This method uses HSETNX to atomically check and set the identifier field, ensuring race-condition-free conditional creation.

Saves the object to Valkey storage only if it doesn't already exist.

Conditionally persists the object to Valkey storage by first checking if the identifier field already exists. If the object already exists in storage, raises an error. Otherwise, proceeds with a normal save operation including automatic timestamping.

This method provides atomic conditional creation to prevent duplicate objects from being saved when uniqueness is required based on the identifier field.

Check if save_if_not_exists is implemented correctly. It should:

Check if record exists If exists, raise Familia::RecordExistsError If not exists, save

Examples:

Save a new user only if it doesn't exist

user = User.new(id: 123, name: "John")
user.save_if_not_exists
# => true (saved successfully)

Attempting to save an existing object

existing_user = User.new(id: 123, name: "Jane")
existing_user.save_if_not_exists
# => raises Familia::RecordExistsError

Save without updating expiration

user.save_if_not_exists(update_expiration: false)
# => true

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the key's expiration time after saving. Defaults to true.

Returns:

  • (Boolean)

    true if the save operation was successful

Raises:

See Also:



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

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,
      "Cannot call save_if_not_exists! within a transaction. This method must be called outside transactions to properly check existence."
  end

  identifier_field = self.class.identifier_field

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

  attempts = 0
  begin
    attempts += 1

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

      txn_result = transaction do |_multi|
        hmset(to_h_for_storage)

        self.update_expiration if update_expiration

        # Auto-index for class-level indexes after successful save
        auto_update_class_indexes
      end

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

      txn_result.successful?
    end
  rescue OptimisticLockError => e
    Familia.ld "[save_if_not_exists]: OptimisticLockError (#{attempts}): #{e.message}"
    raise if attempts >= 3

    sleep(0.001 * (2**attempts))
    retry
  end
end

#transactionObject

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



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

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