Module: SafeMemoize::ClassMethods

Defined in:
lib/safe_memoize/class_methods.rb

Instance Method Summary collapse

Instance Method Details

#memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil) ⇒ Object

Raises:

  • (ArgumentError)


5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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
# File 'lib/safe_memoize/class_methods.rb', line 5

def memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil)
  method_name = method_name.to_sym
  visibility = memoized_method_visibility(method_name)

  cond_if = binding.local_variable_get(:if)
  cond_unless = binding.local_variable_get(:unless)

  ttl = if ttl.nil?
    nil
  else
    ttl = Float(ttl)
    raise ArgumentError, "ttl must be non-negative" if ttl < 0

    ttl
  end

  max_size = if max_size.nil?
    nil
  else
    raise ArgumentError, "max_size must be a positive integer" unless max_size.is_a?(Integer)
    raise ArgumentError, "max_size must be positive" unless max_size > 0

    max_size
  end

  if cond_if && cond_unless
    raise ArgumentError, "cannot specify both :if and :unless"
  end
  raise ArgumentError, ":if must be callable" if cond_if && !cond_if.respond_to?(:call)
  raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)

  # Normalize to a single "should cache?" predicate
  condition = if cond_if
    cond_if
  elsif cond_unless
    ->(result) { !cond_unless.call(result) }
  end

  expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl

  mod = Module.new do
    define_method(method_name) do |*args, **kwargs, &block|
      # Blocks bypass cache entirely — they aren't comparable
      return super(*args, **kwargs, &block) if block

      cache_key = compute_cache_key(method_name, args, kwargs)

      if max_size || condition
        # Locked path: used when LRU tracking or conditional storage is needed.
        memo_mutex!.synchronize do
          record = memo_cache_record(cache_key)
          if record
            lru_touch(method_name, cache_key) if max_size
            record_cache_hit(method_name, args)
            call_memo_hooks(:on_hit, cache_key, record)
            memo_record_value(record)
          else
            start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
            value = super(*args, **kwargs)
            elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time

            if !condition || condition.call(value)
              lru_evict_if_over_limit(method_name, max_size) if max_size
              @__safe_memo_cache__ ||= {}
              @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
              lru_touch(method_name, cache_key) if max_size
            end
            record_cache_miss(method_name, args, elapsed_time)

            value
          end
        end
      else
        # Fast path: check without lock
        if (record = memo_cache_record(cache_key))
          record_cache_hit(method_name, args)
          call_memo_hooks(:on_hit, cache_key, record)
          return memo_record_value(record)
        end

        # Cache miss - compute and store
        start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
        elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time

        with_memo_lock do
          record_cache_miss(method_name, args, elapsed_time)
        end

        result
      end
    end

    send(visibility, method_name)
  end

  prepend mod
end