Class: CMDx::Idempotency

Inherits:
Object
  • Object
show all
Defined in:
lib/cmdx/idempotency.rb

Overview

Declared via Task.idempotent_by. After inputs resolve, claims a shared store slot and runs the retry_on loop inside that claim so duplicate invocations with the same logical key short-circuit before work repeats.

Store keys are namespace:TaskName:segment (see StoreKey#compose). Values move processing → completed after the wrapped block returns (including output verification when invoked from Runtime); the key is deleted on StandardError or non-success signals (skip! / fail!) so retries can reclaim.

Constant Summary collapse

PROCESSING =
"processing"
COMPLETED =
"completed"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key, options = EMPTY_HASH) ⇒ Idempotency

Returns a new instance of Idempotency.

Parameters:

  • key (Symbol, String, Proc, #call, nil) —

    segment resolved per task; nil disables idempotency. Symbols read from context; Proc / blocks are instance_exec'd on the task (with task as the first block argument, same as callbacks); other #callables receive the task. Blank segments skip claiming.

  • options (Hash{Symbol => Object}) (defaults to: EMPTY_HASH)

Options Hash (options):

  • :ttl (Integer) — default: 300 —

    expiry in seconds for store entries

  • :namespace (String) — default: "cmdx:idem" —

    first segment of the store key

  • :on_duplicate (Symbol) — default: :skip —

    :skip or :fail when the slot is already claimed

  • :if (Symbol, Proc, #call) —

    gate (task) for claiming

  • :unless (Symbol, Proc, #call) —

    gate (task) for claiming



41
42
43
44
# File 'lib/cmdx/idempotency.rb', line 41

def initialize(key, options = EMPTY_HASH)
  @key     = key
  @options = options.freeze
end

Instance Attribute Details

#key ⇒ Object (readonly)

Returns the value of attribute key.



27
28
29
# File 'lib/cmdx/idempotency.rb', line 27

def key
  @key
end

Class Method Details

.disabled ⇒ Idempotency

Returns disabled configuration (no key).

Returns:



21
22
23
# File 'lib/cmdx/idempotency.rb', line 21

def disabled
  DISABLED
end

Instance Method Details

#build(new_key, new_options) ⇒ Idempotency

Returns a new Idempotency layering new_options onto the current one. When new_key is non-nil it replaces the resolver; when new_key is nil, only options merge and the existing resolver is kept. A disabled config stays disabled when only options are passed (a key is still required to enable).

Parameters:

  • new_key (Symbol, String, Proc, #call, nil)
  • new_options (Hash{Symbol => Object})

Returns:



85
86
87
88
89
90
91
92
# File 'lib/cmdx/idempotency.rb', line 85

def build(new_key, new_options)
  return self if new_key.nil? && new_options.empty?

  merged_key = new_key.nil? ? @key : new_key
  merged_options = @options.merge(new_options)

  self.class.new(merged_key, merged_options)
end

#condition_if ⇒ Symbol, ...

Returns:

  • (Symbol, Proc, #call, nil)


62
63
64
# File 'lib/cmdx/idempotency.rb', line 62

def condition_if
  @options[:if]
end

#condition_unless ⇒ Symbol, ...

Returns:

  • (Symbol, Proc, #call, nil)


67
68
69
# File 'lib/cmdx/idempotency.rb', line 67

def condition_unless
  @options[:unless]
end

#enabled? ⇒ Boolean

Returns:

  • (Boolean)


72
73
74
# File 'lib/cmdx/idempotency.rb', line 72

def enabled?
  !key.nil?
end

#namespace ⇒ String

Returns:

  • (String)


47
48
49
# File 'lib/cmdx/idempotency.rb', line 47

def namespace
  @options[:namespace] || "cmdx:idem"
end

#on_duplicate ⇒ Symbol

Returns :skip or :fail.

Returns:

  • (Symbol) —

    :skip or :fail



52
53
54
# File 'lib/cmdx/idempotency.rb', line 52

def on_duplicate
  @options[:on_duplicate] || :skip
end

#process(task) { ... } ⇒ Object

Note:

On duplicate keys, signals skip! or fail! on task — control does not return to the caller of #process.

Runs the given block behind a store claim when #enabled? and gates pass. Releases the claim on StandardError or a non-returning exit (skip!, fail!) so retry_on can run work again; marks the slot completed after the block returns successfully.

Parameters:

Yields:

  • invoked when the claim succeeds or idempotency is inactive

Yield Returns:

  • (Object) —

    return value from the wrapped work (ignored by callers)

Returns:

  • (Object) —

    the block's return value

Raises:

  • (ArgumentError) —

    when on_duplicate is not :skip or :fail

  • (IdempotencyError) —

    when the store refuses the completed transition



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
# File 'lib/cmdx/idempotency.rb', line 107

def process(task)
  return yield unless enabled?
  return yield unless Util.satisfied?(condition_if, condition_unless, task)

  store_key = StoreKey.task_key(task, namespace:, resolver: key)
  return yield if store_key.nil?

  if Stores.set(store_key, PROCESSING, nx: true, ttl:)
    Telemetry.emit(task, :idempotency_claimed, idempotency_key: store_key)

    succeeded = false

    begin
      yield
      succeeded = true
    rescue StandardError
      Stores.del(store_key)
      raise
    ensure
      Stores.del(store_key) unless succeeded
    end

    mark_completed!(store_key, task)
  else
    duplicate!(task, store_key)
  end
end

#ttl ⇒ Integer

Returns:

  • (Integer)


57
58
59
# File 'lib/cmdx/idempotency.rb', line 57

def ttl
  @options[:ttl] || 300
end