Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow objects with memoized values to be marshaled/unmarshaled across Ruby processes #51

Merged
merged 12 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading