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
-
#apply_fields(**fields) ⇒ self
Updates the object by applying multiple field values.
-
#clear_fields! ⇒ void
Clears all fields by setting them to nil.
-
#commit_fields(update_expiration: true) ⇒ Object
Commits object fields to the DB storage.
- #dbclient ⇒ Object
-
#destroy! ⇒ void
Permanently removes this object and its related fields from the DB.
-
#multi_field_fast_write(**kwargs) ⇒ self
Atomically writes multiple fields to the database using a single HMSET.
-
#multi_field_update(**kwargs) ⇒ MultiResult
Updates multiple fields atomically in a Database transaction.
- #pipelined ⇒ Object
-
#refresh ⇒ self
Refreshes object state from the DB and returns self for method chaining.
-
#refresh! ⇒ void
Refreshes the object state from the DB storage.
-
#remove_from_instances! ⇒ Object
Removes this object from the class-level instances sorted set.
-
#save(update_expiration: true) ⇒ Boolean
Persists object state to storage with timestamps, validation, and indexing.
-
#save_fields(*field_names, update_expiration: true) ⇒ self
Persists only the specified fields to Redis.
-
#save_if_not_exists ⇒ Boolean
Non-raising variant of save_if_not_exists!.
-
#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.
-
#save_with_collections(update_expiration: true) { ... } ⇒ Boolean
Saves scalar fields first, then executes collection operations in the block.
-
#touch_instances! ⇒ Object
Updates this object's timestamp in the class-level instances sorted set.
-
#transaction ⇒ Object
Convenience methods that forward to the class method of the same name.
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.
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
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.
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
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.)
The expiration update is only performed for classes that have the expiration feature enabled. For others, it's a no-op.
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.
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 |
#dbclient ⇒ Object
1074 |
# File 'lib/familia/horreum/persistence.rb', line 1074 def dbclient(...) = self.class.dbclient(...) |
#destroy! ⇒ void
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.
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.
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..keys}" end self.class..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.
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.
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 |
#pipelined ⇒ Object
1073 |
# File 'lib/familia/horreum/persistence.rb', line 1073 def pipelined(...) = self.class.pipelined(...) |
#refresh ⇒ self
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.
1009 1010 1011 1012 |
# File 'lib/familia/horreum/persistence.rb', line 1009 def refresh refresh! self end |
#refresh! ⇒ void
This method discards any unsaved changes to the object. Use with caution when the object has been modified but not yet persisted.
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.
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).
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
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
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., 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
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.
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_exists ⇒ Boolean
Non-raising variant of save_if_not_exists!
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.
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).
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.
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 |
#transaction ⇒ Object
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(...) |