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) { ... } ⇒ 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.

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

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to set TTL inside the txn.

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:



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.

Returns:

  • (Boolean)


146
147
148
# File 'lib/familia/horreum/atomic_write.rb', line 146

def atomic_write_mode?
  @atomic_write_active == true
end