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, watch_keys: nil, pre_check: nil) { ... } ⇒ 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, watch_keys: nil, pre_check: nil) { ... } ⇒ Boolean
When +watch_keys+ is set and a WATCH abort triggers a retry, the block is re-executed (up to +max_attempts+ times). Scalar setters and collection mutations are safe to replay (the aborted MULTI discards all queued commands), but side effects outside Redis (logging, external API calls) will fire again. Design blocks to be retry-safe when using +watch_keys+.
+prepare_for_save+ (timestamps, unique-index validation) runs once before the retry loop, not on each attempt. This matches +save_if_not_exists!+ behaviour and keeps timestamps consistent across retries.
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.
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/atomic_write.rb', line 101 def atomic_write(update_expiration: true, watch_keys: nil, pre_check: nil) raise ArgumentError, 'Block required for atomic_write' unless block_given? raise ArgumentError, 'pre_check requires watch_keys' if pre_check && !watch_keys&.any? # 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 if watch_keys&.any? execute_watched_atomic_write(watch_keys, pre_check, update_expiration) { yield } else execute_unwatched_atomic_write(update_expiration) { yield } end 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.
163 164 165 |
# File 'lib/familia/horreum/atomic_write.rb', line 163 def atomic_write_mode? @atomic_write_active == true end |