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.



348
349
350
351
352
353
354
# File 'lib/familia/horreum/persistence.rb', line 348

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:



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/familia/horreum/persistence.rb', line 275

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


432
433
434
435
# File 'lib/familia/horreum/persistence.rb', line 432

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.



248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/familia/horreum/persistence.rb', line 248

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

  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



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

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:



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/familia/horreum/persistence.rb', line 380

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 if available
    self.class.instances.remove(identifier) if self.class.respond_to?(: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



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

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:



489
490
491
492
# File 'lib/familia/horreum/persistence.rb', line 489

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:



458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/familia/horreum/persistence.rb', line 458

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!

  naive_refresh(**fields)
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, Time.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
# 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

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


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

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_existsBoolean

Non-raising variant of save_if_not_exists!

Returns:

  • (Boolean)

    true on success, false if object exists

Raises:



217
218
219
220
221
# File 'lib/familia/horreum/persistence.rb', line 217

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:



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

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

    # 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

#transactionObject

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



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

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