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

Separate setting definitions from config values #138

Merged
merged 4 commits into from
Sep 19, 2022

Conversation

timriley
Copy link
Member

@timriley timriley commented Aug 27, 2022

Overview

In this PR we separate setting definitions (i.e. the objects you create when you call setting :my_setting) from config, which now maintains its values in a separate hash, and stores only a reference to the setting definitions.

This allows settings objects to be shared across all config classes, which, along with some other changes here, reduces memory usage considerably.

According benchmarks/memory_profile.rb for 10,000 subclasses, this branch now allocates only 9.5 MB, down from 21.9 MB on the main branch.

User-facing changes

Unless you're an advanced dry-configurable user here, and reaching deeply into our Setting objects, the user-facing changes here are minimal! Ordinary usages of dry-configurable will continue to work as before.

The one real behavioural change is that for instance-level configurable, new settings defined make themselves available even on previously initialized objects. This is in fact an incidental change to the rest of this PR and I could choose to wind that back, too, if we wanted.

Implementation details

  • Setting moves to being a strict definition only. It no longer plays double duty as a realisation of an associated value within a config object. Specifically, it no longer has an input or value. Because of this, it no longer needs to be finalized or return a pristine copy or other such config-related responsibilities.
  • Setting::Nested and Setting#nested have been removed. These no longer seemed necessary, and we now just provide a simple array of children: when initialising the setting.
  • In turn Settings can lose config-related responsibilities such as #finalize! and #pristine.
  • I renamed the internal Settings collection from elements to settings since it feels nicer.
  • At the same time, this internal collection is no longer a Concurrent::Map, instead just a regular hash. This netted us some decent memory savings. I am keen to hear feedback about this. Why did it need to be a concurrent map in the first place? Are we worried e.g. about settings being defined in concurrent threads? What material downside would there be from this being a hash? And how does this compare with how we do things in e.g. Dry::Core::ClassAttributes, where there's no specific thread safety measures. Is calling setting here really all that different to defines from ClassAttributes?
  • Config is now initialized with a settings instance, which it uses to determine which values it should be storing. These values are kept internally in a simple name=>value hash.
  • When configurable classes are inherited, we create a dup of the _settings collection to the subclass. This dups its internal settings array only, so we don't copy all of the settings themselves, thereby preserving memory.
  • Likewise, we create a copy of the config into the subclass using a new Config#dup_for_settings method, passing the new subclass settings collection to ensure the config object sees any new settings defined in the subclass. When we #dup_for_settings, we also dup any cloneable values that the config holds (i.e. values likely to be mutated and therefore ideally not shared), but we do not dup any of the other simple values, since they are safer to be shared.

Next steps

In the shortest term, I'm going to let @solnic merge #136 when he is ready, and then I'll make sure I update this PR to work with those changes. I expect it should only need minimal adjustment, mostly just moving of code.

I also expect to follow this up in the next week with another proposal for further reducing dry-configurable memory usage, but this PR is already a good step in a positive direction, and I think it also helpfully clarifies the code at the same time.

@timriley

This comment was marked as outdated.

@timriley

This comment was marked as outdated.

@timriley

This comment was marked as outdated.

@timriley

This comment was marked as outdated.

@timriley timriley marked this pull request as ready for review September 13, 2022 13:32
Comment on lines +18 to +21
# Only classes **extending** Dry::Configurable have class-level config. When
# Dry::Configurable is **included**, the class-level config method is undefined because it
# resides at the instance-level instead (see `Configurable.included`).
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this note here because I remember being confused in the past about why we may not respond to config despite it being defined right here in this module. Hopefully this helps alleviate any confusion from readers in the future.

Comment on lines -50 to +59
@settings ||= Set[*_settings.map(&:name)]
Set[*_settings.map(&:name)]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to memoize this because it'll capture stale data if called before all settings are defined.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timriley I would remove this, we have _settings and this one should be exposed as settings IMO. This Set is mostly here for historical reasons, it was just always like that but I'm not sure if this is a valuable thing to keep.

Copy link
Member Author

@timriley timriley Sep 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@solnic Oh great, I'm glad you think this! The settings/_settings disparity has always bothered me (especially since the more useful of the two objects is the one with the ugly underscore prefix).

I'll be happy to update it, but I'll do it in a separate PR (once this is merged) for better visibility.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timriley the only reason why I introduced _settings was because I didn't want to break backward compatibility by changing settings from a set of symbol names to an array of setting objects.

Comment on lines -32 to +34
visit(parent).nested(call(children))
name, opts = parent[1]

Setting.new(name, **opts, children: Settings.new(call(children)))
Copy link
Member Author

@timriley timriley Sep 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To save ourselves from creating a throwaway object (i.e. a Setting that we initialize and then have to re-initialize with the children), we build a single Setting instance here directly rather than running its part of the AST back through the compiler.

Comment on lines -105 to +135
if setting.writer?(meth)
raise FrozenConfig, "Cannot modify frozen config" if frozen?

_settings << setting.with(input: args[0])
if name.end_with?("=")
self[setting_name] = args[0]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified reading and writing of values here by moving all such logic into the #[] and #[]= methods, rather than it being spread between them and #method_missing.

@timriley
Copy link
Member Author

@solnic
Copy link
Member

solnic commented Sep 13, 2022

custom config support was merged to main but I'm not sure how it's going to work here given that crucial construction logic was moved to the default Config class?

@timriley timriley force-pushed the separate-setting-definitions branch 4 times, most recently from 8e514e4 to 79cfb72 Compare September 14, 2022 20:44
@timriley
Copy link
Member Author

custom config support was merged to main but I'm not sure how it's going to work here given that crucial construction logic was moved to the default Config class?

I've rebased over the main branch now (and tidied my commit history) and everything is working fine! (👋🏼 @solnic)

If you'd like to review it, the Config construction logic is in these places:

  • ClassMethods#__config_build__ (same as your recent PR)
  • Setting#to_value (changed for this PR, this used to be in the now-removed Setting::Nested class)

Overall, this PR is ready for review and merge once we're all good with it.

# Dry::Configurable is **included**, the class-level config method is undefined because it
# resides at the instance-level instead (see `Configurable.included`).
if respond_to?(:config)
subclass.instance_variable_set(:@config, config.dup_for_settings(new_settings))
Copy link
Member Author

@timriley timriley Sep 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, until now we had been setting :@_config, when the actual memoized instance variable (used further below in the #config method) is @config. Setting this to the memoized ivar is in fact the desired behaviour here, so I think before now things must have been working accidentally.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timriley it'd be good to decide on just one approach for naming private ivars, in hanami I noticed we have _foo, in dry-rb we have __foo__. I'm just mentioning it here for future consideration.

@@ -8,16 +8,12 @@ module Configurable
class DSL
VALID_NAME = /\A[a-z_]\w*\z/i.freeze

# @api private
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole class is marked as # @api private so these lines aren't needed.

This reduces memory usage considerably, and improves separation of concerns.
This saves us a reasonable amount of memory.
Now that settings and config are separated, we don't need to dup the settings anymore. This allows the config to use settings defined even after instances of the configurable class are made.
Copy link
Member

@solnic solnic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good overall, I left some suggestions that may improve performance a bit.

# Dry::Configurable is **included**, the class-level config method is undefined because it
# resides at the instance-level instead (see `Configurable.included`).
if respond_to?(:config)
subclass.instance_variable_set(:@config, config.dup_for_settings(new_settings))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timriley it'd be good to decide on just one approach for naming private ivars, in hanami I noticed we have _foo, in dry-rb we have __foo__. I'm just mentioning it here for future consideration.

Comment on lines -50 to +59
@settings ||= Set[*_settings.map(&:name)]
Set[*_settings.map(&:name)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timriley the only reason why I introduced _settings was because I didn't want to break backward compatibility by changing settings from a set of symbol names to an array of setting objects.

lib/dry/configurable/config.rb Outdated Show resolved Hide resolved
lib/dry/configurable/config.rb Outdated Show resolved Hide resolved
lib/dry/configurable/setting.rb Outdated Show resolved Hide resolved
lib/dry/configurable/settings.rb Outdated Show resolved Hide resolved
@timriley
Copy link
Member Author

Thanks @solnic! I'll incorporate all of your suggestions :)

And just to make sure, are you and @flash-gordon both fine with the replacement of the Concurrent::Map with an ordinary Hash for containing the setting definitions (inside Settings)?

@solnic
Copy link
Member

solnic commented Sep 16, 2022

And just to make sure, are you and @flash-gordon both fine with the replacement of the Concurrent::Map with an ordinary Hash for containing the setting definitions (inside Settings)?

@timriley yes this is fine. If thread-safety is an issue here, some external mechanism should be incorporated to ensure the safety. Doing so at each library level turned out to be a bad idea, as we've learned 🙂

@flash-gordon
Copy link
Member

And just to make sure, are you and @flash-gordon both fine with the replacement of the Concurrent::Map with an ordinary Hash for containing the setting definitions (inside Settings)?

I don't think this can lead to problems, practically speaking. Your reasoning is the same as mine, why would you have an environment where you define settings concurrently? AFAIK, require is thread-safe. There are possible other use cases but I agree with @solnic that they should be synchronized externally.

@timriley
Copy link
Member Author

Thank you all! Merging this one now.

@timriley timriley merged commit 35f34b2 into main Sep 19, 2022
@timriley timriley deleted the separate-setting-definitions branch September 19, 2022 20:02
timriley added a commit to hanami/controller that referenced this pull request Oct 7, 2022
… config_class (#392)

Take advantage of the recent memory usage improvements in dry-configurable (see dry-rb/dry-configurable#138) by converting the previously custom `Configuration` class into settings defined on the `Hanam::Action` class.

To preserve some of the existing custom configuration methods, provide a custom `Hanami::Action::Config` config class to `Dry::Configurable` and keep those methods there.

This custom config class also comes in handy as the place to keep the documentation for our settings, which I've converted into yard `@!attribute` macros for concision.

We also move two existing bits of state into new dry-configurable settings:

- Convert `before_callbacks` and `after_callbacks` from class attributes, since Hanami-utils' class attributes implementation is very inefficient with memory.
- Move `accepted_formats` from being an class-level ivar, since this was the only remaining class-level state and it made sense to keep all this together via the class-level config object.

As a consequence of the last change, we're also able to change `Hanami::Action` so it has a conventional `#initialize` method again, rather than having a custom `.new` method that has to `allocate` the object and assign some ivars directly, which is a big win for ease of understanding the code :)

Lastly, while we're here, we get rid of the @name ivar for actions, since it's no longer used anywhere.

Using the newly added `benchmarks/memory_profile_action.rb` (which creates 100 `Action` subclasses by default) we can see this branch allocates 0.338 MB of memory, versus 1.859 MB for the main branch.
Copy link
Contributor

@ojab ojab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH no idea how this supposed to be used after reading the specs.

end

it "compiles but deprecates giving a default as positional argument", :collect_deprecations do
setting = dsl.setting :user, "root"

expect(setting.name).to be(:user)
expect(setting.value).to eql("root")
expect(setting.default).to eq("root")
# expect(setting.value).to eql("root")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be removed or uncommented (and below)?

@timriley
Copy link
Member Author

TBH no idea how this supposed to be used after reading the specs.

@ojab What in particular are you looking to find out?

That the specs didn't change much indicates that overall usage remains the same, especially if you're just using setting and config.

The biggest difference is that _settings now returns an array of Dry::Configurable::Setting objects that contain the details of the setting definition only; the details about the configured values of the settings are kept in Dry::Configurable::Config now, separated from the Setting instances.

@timriley
Copy link
Member Author

timriley commented Oct 19, 2022

@ojab Ah, if your comment was meant specifically about whether to leave or remove these lines from spec/unit/configurable/dsl_spec.rb:

# expect(setting.value).to eql("root")

Then the answer is that they should be removed :) Setting instances no longer hold values.

Looks like I cleaned them up in 22ab1fa, anyway.

@ojab
Copy link
Contributor

ojab commented Oct 22, 2022

Thanks, I was trying to understand if there should be any user-visible changes, now I see that it's the internal change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants