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

Instance Method Details

#atomic_write(update_expiration: true, watch_keys: nil, pre_check: nil) { ... } ⇒ Boolean

Note:

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+.

Note:

+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.

Examples:

Atomically update scalar fields and a set

plan.atomic_write do
  plan.name = "Premium"
  plan.region = "US"
  plan.features.clear
  plan.features.add("sso")
end

Race-safe create with WATCH (used by build)

user.atomic_write(
  watch_keys: [user.dbkey],
  pre_check: -> { raise RecordExistsError, user.dbkey if user.exists? }
) { user.tags.add("new") }

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to set TTL inside the txn.

  • watch_keys (Array<String>, nil) (defaults to: nil)

    Optional list of Redis keys to WATCH before opening the MULTI. When provided, the transaction is wrapped in a WATCH block: if any watched key is modified between the WATCH and EXEC, Redis aborts the transaction and the method retries (up to 3 attempts with exponential backoff). This enables optimistic locking patterns such as "create-only" semantics in +build+.

  • pre_check (Proc, nil) (defaults to: nil)

    Optional callable executed between WATCH and MULTI -- the only window where reads return real values (not Redis::Future objects) while the watched keys are still guarded. Typically used for existence checks that should abort early: pre_check: -> { raise RecordExistsError, dbkey if exists? } Requires +watch_keys+ to be set.

Yields:

  • Block containing field assignments and collection mutations.

Returns:

  • (Boolean)

    true if the transaction's EXEC completed and every queued command returned without an exception; false if the transaction was discarded or any queued command returned an error.

Raises:

See Also:



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.

Returns:

  • (Boolean)


163
164
165
# File 'lib/familia/horreum/atomic_write.rb', line 163

def atomic_write_mode?
  @atomic_write_active == true
end