/process_settings

dynamic process settings

Primary LanguageRubyMIT LicenseMIT

ProcessSettings

This gem provides dynamic settings for Ruby processes. The settings are stored in YAML. Settings are managed in a git repo, in separate YAML files for each concern (for example, each micro-service). Each YAML file can be targeted based on matching context values (for example, service_name).

The context can be either static to the process (for example, service_name or datacenter) or dynamic (for example, the current web request domain).

Dependencies

  • Ruby >= 2.6
  • ActiveSupport >= 4.2, < 7

Installation

To install this gem directly on your machine from rubygems, run the following:

gem install process_settings

To install this gem in your bundler project, add the following to your Gemfile:

gem 'process_settings', '~> 0.4'

Usage

The ProcessSettings::FileMonitor and related classes can be freely created and used at any time. But typical usage is through the ProcessSettings.instance. That should be configured at process startup before use.

Configuration

Before using ProcessSettings.instance, you must first configure the path to the combined process settings file on disk, and provide a logger.

require 'process_settings'

ProcessSettings.instance = ProcessSettings::FileMonitor.new("/etc/process_settings/combined_process_settings.yml",
                                                            logger: logger)

Instance Initialization

The ProcessSettings is a hybrid singleton. The class attribute instance returns the current instance as set at configuration time. Deprecated: If not already set, this is lazy-created based on the above configuration.

The monitor should be initialized with static (unchanging) context for your process:

ProcessSettings.instance.static_context = {
  "service_name" => "frontend",
  "datacenter" => "AWS-US-EAST-1"
}

The static_context is important because it is used to pre-filter settings for the process. For example, a setting that is targeted to service_name: frontend will match the above static context and be simplified to true. In other processes with a different service_name, such a targeted setting will be simplified to false and removed from memory.

Note that the static_context as well as dynamic_context must use strings, not symbols, for both keys and values.

Reading Settings

For the following section, consider this combined_process_settings.yml file:

---
- filename: frontend.yml
  settings:
    frontend:
      log_level: info
- filename: frontend-microsite.yml
  target:
    domain: microsite.example.com
  settings:
    frontend:
      log_level: debug
- meta:
    version: 27
    END: true

To read a setting, application code should call the [] method on the ProcessSettings class. For example:

log_level = ProcessSettings['frontend', 'log_level']
=> "info"

ProcessSettings[] interface

The ProcessSettings[] method delegates to ProcessSettings.instance#[] on the instance.

[] interface:

[](*path, dynamic_context: {}, required: true)
argument description
path A series of 1 or more comma-separated strings forming a path to navigate the settings hash, starting at the top.
dynamic_context: An optional hash of dynamic settings, used to target the settings. This will automatically be deep-merged with the static context. It may not contradict the static context.
required: A boolean indicating if the setting is required to be present. If a setting is missing, then if required is truthy, a ProcesssSettings::SettingsPathNotFound exception will be raised. Otherwise, nil will be returned. Default: true.

Example with dynamic_context:

log_level = ProcessSettings['frontend', 'log_level',
                            dynamic_context: { "domain" => "microsite.example.com" }
                           ]
=> "debug"

Example with required: true (default) that was not found:

http_version = ProcessSettings['frontend', 'http_version']

exception raised!

ProcessSettings::SettingsPathNotFound: No settings found for path ["frontend", "http_version"]

Here is the same example with required: false, applying a default value of 2:

http_version = ProcessSettings['frontend', 'http_version', required: false] || 2

Dynamic Settings

The ProcessSettings::FileMonitor loads settings changes dynamically whenever the file changes, by using the listen gem which in turn uses the INotify module of the Linux kernel, or FSEvents on MacOS. There is no need to restart the process or send it a signal to tell it to reload changes.

There are two ways to get access the latest settings from inside the process:

Read Latest Setting Through ProcessSettings[]

The simplest approach--as shown above--is to read the latest settings at any time through ProcessSettings[] (which delegates to ProcessSettings.instance):

http_version = ProcessSettings['frontend', 'http_version']

Register a when_updated Callback

Alternatively, if you need to execute initially and whenever the value is updated, register a callback with ProcessSettings.instance#when_updated:

ProcessSettings.instance.when_updated do
  logger.level = ProcessSettings['frontend', 'log_level']
end

By default, the when_updated block is called initially when registered. We've found this to be convenient in most cases; it can be disabled by passing initial_update: false, in which case the block will be called 0 or more times in the future, when any of the process settings for this process change.

when_updated is idempotent.

In case you need to cancel the callback later, when_updated returns a handle (the block itself) which can later be passed into cancel_when_updated.

Note that all callbacks run sequentially on the shared change monitoring thread, so please be considerate!

Targeting

Each settings YAML file has an optional target key at the top level, next to settings.

If there is no target key, the target defaults to true, meaning all processes are targeted for these settings. (However, the settings may be overridden by other YAML files. See "Precedence" below.)

Hash Key-Values Are AND'd

To target on context values, provide a hash of key-value pairs. All keys must match for the target to be met. For example, consider this target hash:

target:
  service_name: frontend
  datacenter: AWS-US-EAST-1

This will be applied in any process that has service_name == "frontend" AND is running in datacenter == "AWS-US-EAST-1".

Multiple Values Are OR'd

Values may be set to an array, in which case the key matches if any of the values matches. For example, consider this target hash:

target:
  service_name: [frontend, auth]
  datacenter: AWS-US-EAST-1

This will be applied in any process that has (service_name == "frontend" OR service_name == "auth") AND datacenter == "AWS-US-EAST-1".

Precedence

The settings YAML files are always combined in alphabetical order by file path. Later settings take precedence over the earlier ones.

Forked Processes

When using ProcessSettings within an environment that is forking threads (like unicorn web servers), you can restart the FileMonitor after the fork with restart_after_fork.

# unicorn.rb

preload_app true

after_fork do
  ProcessSettings.instance.restart_after_fork
end

Start Watchdog

When using ProcessSettings you can start a watchdog thread using start_watchdog_thread on a particular file path. The watchdog will poll once a minute to double-check if any changes have been missed due to a bug in the listen gem or the supporting OS drivers like inotify. You may not start the watchdog thread it has already been started.

ProcessSettings.instance.start_watchdog_thread(file_path)

Stop Watchdog

When using ProcessSettings you can stop a watchdog thread using stop_watchdog_thread. Once stopped you may start a new watchdog thread.

ProcessSettings.instance.stop_watchdog_thread

Testing

For testing, it is often necessary to set a specific override hash for the process_settings values to use in that use case. The ProcessSettings::Testing::RSpec::Helpers and ProcessSettings::Testing::Minitest::Helpers modules are provided for this purpose. They can be used to override a specific hash of process settings, while leaving the rest intact, and resetting back to the defaults after the test case is over. Here are some examples using various testing frameworks:

RSpec

spec_helper.rb
require 'process_settings/testing/helpers'

RSpec.configure do |config|
  # ...

  config.include ProcessSettings::Testing::RSpec::Helpers

  # Note: the include above will automatically register a global after block that will reset process_settings to their initial values.
  # ...
end
process_settings_spec.rb
require 'spec_helper'

RSpec.describe SomeClass do
  before do
    stub_process_settings(honeypot: { answer_odds: 100 })
  end

  # ...
end

Test::Unit / Minitest

require 'process_settings/testing/helpers'

context SomeClass do
  include ProcessSettings::Testing::Minitest::Helpers

  # Note: the include above will automatically register a teardown block that will reset process_settings to their initial values.

  setup do
    stub_process_settings(honeypot: { answer_odds: 100 })
  end

  # ...
end

Contributions

Contributions to this project are always welcome. Please thoroughly read our Contribution Guidelines before starting any work.