Skip to content

Commit

Permalink
Allow objects with memoized values to be marshaled/unmarshaled across…
Browse files Browse the repository at this point in the history
… Ruby processes (#51)
  • Loading branch information
obrie authored Mar 4, 2025
1 parent fceb1dd commit 582f807
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 4 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,36 @@ a.memoized?(:call) # => true
a.memoized?(:execute) # => false
```

### Marshal-compatible Memoization

In order for objects to be marshaled and loaded in a different Ruby process,
hashed arguments must be disabled in order for memoized values to be retained.
Note that this can have a performance impact if the memoized method contains
arguments.

```ruby
Memery.use_hashed_arguments = false

class A
include Memery

memoize def call
puts "calculating"
42
end
end

a = A.new
a.call

Marshal.dump(a)
# => "\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323"

# ...in another Ruby process:
a = Marshal.load("\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323")
a.call # => 42
```

## Differences from Other Gems

Memery is similar to [Memoist](https://github.com/matthewrudy/memoist), but it doesn't override methods. Instead, it uses Ruby 2's `Module.prepend` feature. This approach is cleaner, allowing you to inspect the original method body with `method(:x).super_method.source`, and it ensures that subclasses' methods function properly. If you redefine a memoized method in a subclass, it won't be memoized by default. You can memoize it normally without needing an awkward `identifier: ` argument, and it will just work:
Expand Down
26 changes: 26 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ def base_find(char)
memoize def find_new(char)
base_find(char)
end

memoize def find_optional(*)
base_find("z")
end
end
end

Expand All @@ -43,6 +47,10 @@ def test_with_args
Foo.find_new("d")
end

def test_empty_args
Foo.find_optional
end

Benchmark.ips do |x|
x.report("test_no_args") { test_no_args }
end
Expand All @@ -51,6 +59,14 @@ def test_with_args
x.report("test_no_args") { 100.times { test_no_args } }
end

Benchmark.ips do |x|
x.report("test_empty_args") { test_empty_args }
end

Benchmark.memory do |x|
x.report("test_empty_args") { 100.times { test_empty_args } }
end

Benchmark.ips do |x|
x.report("test_with_args") { test_with_args }
end
Expand All @@ -59,4 +75,14 @@ def test_with_args
x.report("test_with_args") { 100.times { test_with_args } }
end

Memery.use_hashed_arguments = false
Benchmark.ips do |x|
x.report("test_with_args_no_hash") { test_with_args }
end

Benchmark.memory do |x|
x.report("test_with_args_no_hash") { 100.times { test_with_args } }
end
Memery.use_hashed_arguments = true

puts "```"
19 changes: 15 additions & 4 deletions lib/memery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

module Memery
class << self
attr_accessor :use_hashed_arguments

def monotonic_clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end

@use_hashed_arguments = true

OUR_BLOCK = lambda do
extend(ClassMethods)
include(InstanceMethods)
Expand Down Expand Up @@ -68,19 +72,25 @@ def fresh?(ttl)
end
end

# rubocop:disable Metrics/MethodLength
def define_memoized_method!(klass, method_name, condition: nil, ttl: nil)
method_key = "#{method_name}_#{object_id}"

# Include a suffix in the method key to differentiate between methods of the same name
# being memoized throughout a class inheritance hierarchy
method_key = "#{method_name}_#{klass.name || object_id}"
original_visibility = method_visibility(klass, method_name)
original_arity = klass.instance_method(method_name).arity

define_method(method_name) do |*args, &block|
if block || (condition && !instance_exec(&condition))
return super(*args, &block)
end

cache_store = (@_memery_memoized_values ||= {})
cache_key = original_arity.zero? ? method_key : [method_key, *args].hash
cache_key = if args.empty?
method_key
else
key_parts = [method_key, *args]
Memery.use_hashed_arguments ? key_parts.hash : key_parts
end
cache = cache_store[cache_key]

return cache.result if cache&.fresh?(ttl)
Expand All @@ -95,6 +105,7 @@ def define_memoized_method!(klass, method_name, condition: nil, ttl: nil)
ruby2_keywords(method_name)
send(original_visibility, method_name)
end
# rubocop:enable Metrics/MethodLength

private

Expand Down
42 changes: 42 additions & 0 deletions spec/memery_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ class H

before { CALLS.clear }
before { B_CALLS.clear }
before { Memery.use_hashed_arguments = true }

let(:unmemoized_class) do
Class.new do
Expand Down Expand Up @@ -263,6 +264,27 @@ class H
end
end

context "anonymous inherited class" do
let(:anonymous_class) do
Class.new(A) do
memoize def m_args(x, y)
B_CALLS << [x, y]
super(1, 2)
100
end
end
end

subject(:b) { anonymous_class.new }

specify do
values = [ b.m_args(1, 1), b.m_args(1, 2), b.m_args(1, 1) ]
expect(values).to eq([100, 100, 100])
expect(CALLS).to eq([[1, 2]])
expect(B_CALLS).to eq([[1, 1], [1, 2]])
end
end

context "module" do
subject(:c) { C.new }

Expand Down Expand Up @@ -336,6 +358,26 @@ class H
end
end

context "without hashed arguments" do
before { Memery.use_hashed_arguments = false }

context "methods without args" do
specify do
values = [ a.m, a.m_nil, a.m, a.m_nil ]
expect(values).to eq([:m, nil, :m, nil])
expect(CALLS).to eq([:m, nil])
end
end

context "method with args" do
specify do
values = [ a.m_args(1, 1), a.m_args(1, 1), a.m_args(1, 2) ]
expect(values).to eq([[1, 1], [1, 1], [1, 2]])
expect(CALLS).to eq([[1, 1], [1, 2]])
end
end
end

describe ":condition option" do
before do
a.environment = environment
Expand Down

0 comments on commit 582f807

Please sign in to comment.