Module: Familia::Horreum::AtomicWrite
- Included in:
- Familia::Horreum
- Defined in:
- lib/familia/horreum/atomic_write.rb
Overview
AtomicWrite - Wraps scalar field persistence and collection mutations in a single MULTI/EXEC transaction.
Unlike Persistence#save_with_collections, which sequences two separate writes (scalars first, then block), +atomic_write+ composes Familia's existing +transaction+ infrastructure so every command -- the HMSET for scalar fields, the expiration/index/instances bookkeeping, and all collection mutations executed inside the block -- lands in one atomic MULTI/EXEC. This is made possible by the fact that +DataType#dbclient+ already reads +Fiber[:familia_transaction]+ when set, so any call to +plan.features.add(...)+ inside the block transparently routes its command to the open transaction connection.
Because MULTI/EXEC cannot span multiple Redis databases, a pre-flight guard (+guard_atomic_write_database!+) rejects any configuration where a related field declares a +logical_database+ that differs from the parent Horreum's. In that case callers should fall back to Persistence#save_with_collections.
See issue #220 for the design rationale.
Constant Summary collapse
- OWNER_STATE_MUTEX =
Module-level mutex guarding the per-instance owner CAS. Held only for the ivar read/write pairs that compose the re-entrancy check, so contention is negligible even with many concurrent instances.
Mutex.new
Instance Method Summary collapse
-
#atomic_write(update_expiration: true) { ... } ⇒ Boolean
Persists scalar fields and collection operations atomically in a single MULTI/EXEC transaction.
-
#atomic_write_mode? ⇒ Boolean
Returns true while inside an #atomic_write block.
Instance Method Details
#atomic_write(update_expiration: true) { ... } ⇒ Boolean
Persists scalar fields and collection operations atomically in a single MULTI/EXEC transaction.
Scalar field assignments inside the block only mutate in-memory state (deferred writes); the HMSET is issued by Persistence#persist_to_storage inside the transaction. Collection mutations (e.g. +plan.features.add+) execute immediately against the open transaction connection because +DataType#dbclient+ honours +Fiber[:familia_transaction]+.
Unique index validation (+prepare_for_save+) runs OUTSIDE the transaction so it can perform the reads it needs. Only the writes are atomic; the read-validate-write is not.
69 70 71 72 73 74 75 76 77 78 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 119 120 121 122 |
# File 'lib/familia/horreum/atomic_write.rb', line 69 def atomic_write(update_expiration: true) raise ArgumentError, 'Block required for atomic_write' unless block_given? # Mirror save's nesting guard -- atomic_write opens its own MULTI and # cannot be nested inside an outer transaction (see Persistence#save). if Fiber[:familia_transaction] raise Familia::OperationModeError, <<~ERROR_MESSAGE Cannot call atomic_write within an existing transaction. atomic_write opens its own MULTI/EXEC and cannot be nested. ERROR_MESSAGE end # Same-instance re-entrancy guard. The Fiber[:familia_transaction] # check is Fiber-local, so it only protects re-entry within the same # Fiber. A second Fiber or Thread touching the same Horreum instance # would otherwise open a parallel MULTI against shared scalar state # and race HMSET -- defeating the "atomic" contract. The check-then- # set on @atomic_write_owner is serialised under OWNER_STATE_MUTEX so # two threads can't both observe a nil owner and simultaneously claim # ownership. acquire_atomic_write_ownership! begin guard_atomic_write_database! # prepare_for_save must run OUTSIDE the transaction: guard_unique_indexes! # performs reads, which return uninspectable Redis::Future objects inside # MULTI/EXEC. prepare_for_save result = transaction do |_conn| # Yield FIRST so scalar setters mutate ivars and collection mutations # queue their commands (SADD, ZADD, etc.) in the open MULTI. # Collection mutations auto-route via Fiber[:familia_transaction] # (see DataType#dbclient). yield # Then queue the HMSET for scalar fields. to_h_for_storage snapshots # ivars at command-queue time, so any assignments made inside the # block are captured. Also queues expiration, class indexes, and # touch_instances!. persist_to_storage(update_expiration) end # A MultiResult is always returned by `transaction` -- inspect its # successful? flag rather than testing for nil. Individual commands # inside MULTI return exception objects (rather than raising) when # they fail; successful? is false if any of those slipped through. success = atomic_write_success?(result) clear_dirty! if success success ensure release_atomic_write_ownership! end end |
#atomic_write_mode? ⇒ Boolean
Returns true while inside an #atomic_write block.
Consulted by +Familia::DataType#warn_if_dirty!+ to suppress the dirty-state warning for collection mutations that legitimately run against dirty in-memory scalars inside an atomic_write block (the scalars will be persisted by the same transaction).
This predicate is intended to be queried from the same Fiber/Thread that owns the active atomic_write block. The +@atomic_write_active+ ivar is read without the +OWNER_STATE_MUTEX+ that guards #acquire_atomic_write_ownership!, so a query issued from a different Fiber or Thread is advisory and may observe stale state (either a +true+ that has just been cleared, or a +false+ that has just been set). This is by design: the sole intended caller -- +Familia::DataType#warn_if_dirty!+ -- runs from the same Fiber that invoked +atomic_write+, so the read is always consistent in the cases that matter. Adding a lock on every collection mutation purely to make a single advisory log line precise across Fibers/Threads would be the wrong tradeoff.
146 147 148 |
# File 'lib/familia/horreum/atomic_write.rb', line 146 def atomic_write_mode? @atomic_write_active == true end |