Skip to content
21 changes: 12 additions & 9 deletions lib/ldclient-rb/impl/file_data/reloader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
require "ldclient-rb/impl/file_data/document"
require "ldclient-rb/impl/file_data/merge"
require "ldclient-rb/impl/file_data/poller"
require "ldclient-rb/impl/util"

require "digest"

Expand Down Expand Up @@ -97,6 +96,7 @@ class Reloader
# is parsed as it is.
# @param skip_unchanged [Boolean] if true, `apply` is not invoked when the files' raw
# contents are byte-identical to the last successfully applied contents.
# @param log_prefix [String] the prefix of every log line this reloader writes
# @param clock [#call, nil] returns the seconds of a monotonic clock, for the time a file has
# been held back; nil for the system's. Scheduling always uses the system's clock.
#
Expand All @@ -106,9 +106,11 @@ def initialize(paths:, logger:, apply:, on_error: nil,
debounce_delay: DEFAULT_DEBOUNCE_DELAY,
retry_delay: DEFAULT_RETRY_DELAY,
skip_unchanged: false,
log_prefix: "[LDClient]",
clock: nil)
@paths = paths
@logger = logger
@log_prefix = log_prefix
@apply = apply
@on_error = on_error
@duplicate_keys_handling = duplicate_keys_handling
Expand Down Expand Up @@ -221,7 +223,7 @@ def stop

# An unexpected error must not end the worker: no reload would happen after it.
begin
@logger.info { "[LDClient] Reloading flag data after detecting a change" } if action == :reload
@logger.info { "#{@log_prefix} Reloading flag data after detecting a change" } if action == :reload
retry_in = reload(retrying: action == :retry)
@mutex.synchronize do
# A pending retry is superseded by this reload. The reload either succeeded, or it
Expand All @@ -240,7 +242,8 @@ def stop
end

private def log_unexpected_error(error)
Util.log_exception(@logger, "Unexpected error in file data reloader", error)
@logger.error { "#{@log_prefix} Unexpected error in file data reloader: #{error.inspect}" }
@logger.debug { "#{@log_prefix} Exception trace: #{error.backtrace}" }
end

#
Expand Down Expand Up @@ -296,9 +299,9 @@ def stop

if retrying
if @read_again_pending
@logger.debug { "[LDClient] Reading flag data again after a file was found empty" }
@logger.debug { "#{@log_prefix} Reading flag data again after a file was found empty" }
else
@logger.debug { "[LDClient] Retrying flag data load after earlier failure" }
@logger.debug { "#{@log_prefix} Retrying flag data load after earlier failure" }
end
end
@read_again_pending = false
Expand Down Expand Up @@ -349,7 +352,7 @@ def stop
reached << path
end
if e.missing && @skip_missing_paths
@logger.debug { "[LDClient] File #{path} does not exist; it contributes no data" }
@logger.debug { "#{@log_prefix} File #{path} does not exist; it contributes no data" }
had_content[path] = false
append_to_digest(digest, path, nil)
files << FileSummary.new(path, false, 0, 0)
Expand Down Expand Up @@ -520,7 +523,7 @@ def stop
retry_at = nil
paths.each do |path|
@logger.debug do
"[LDClient] File #{path} is empty, possibly while it is being rewritten; reading it again before applying it"
"#{@log_prefix} File #{path} is empty, possibly while it is being rewritten; reading it again before applying it"
end
empty = @empty_files[path]
over = [empty.since + @retry_delay, empty.first_held + MAX_HOLD_RETRY_DELAYS * @retry_delay].min
Expand All @@ -539,12 +542,12 @@ def stop
# level and do not re-invoke on_error.
message = error.message
if message == @last_error_message
@logger.debug { "[LDClient] Unable to load flags: #{message}" }
@logger.debug { "#{@log_prefix} Unable to load flags: #{message}" }
return @retry_delay
end

@last_error_message = message
@logger.error { "[LDClient] Unable to load flags: #{message}" }
@logger.error { "#{@log_prefix} Unable to load flags: #{message}" }
@on_error&.call(error)
@retry_delay
end
Expand Down
151 changes: 151 additions & 0 deletions lib/ldclient-rb/impl/integrations/file_override_source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# frozen_string_literal: true

require "ldclient-rb/impl/file_data"
require "ldclient-rb/interfaces/overrides"

module LaunchDarkly
module Impl
module Integrations
#
# The file-based override source. It reads flag and segment overrides from one or more local
# files and reloads them as the files change. See
# {LaunchDarkly::Integrations::FileData.override_source} for the public API and its options.
#
# Flag overrides are currently experimental and subject to change.
#
# @private
#
class FileOverrideSource
include LaunchDarkly::Interfaces::Overrides::OverrideSource

LOG_PREFIX = "[LDClient] FileOverrideSource:"

#
# @param paths [Array<String>] absolute file paths, in precedence order
# @param duplicate_keys_handling [Symbol] `:fail` or `:ignore`
# @param change_detection [Symbol] `:polling` or `:watching`
# @param poll_interval [Numeric] seconds between examinations in polling mode
# @param logger [Logger]
#
def initialize(paths:, duplicate_keys_handling:, change_detection:, poll_interval:, logger:)
@paths = paths
@duplicate_keys_handling = duplicate_keys_handling
@change_detection = change_detection
@poll_interval = poll_interval
@logger = logger
@lock = Mutex.new
@reloader = nil
@change_detector = nil
@stopped = false
end

# @return [Array<String>]
attr_reader :paths

# @return [Symbol]
attr_reader :duplicate_keys_handling

# @return [Symbol]
attr_reader :change_detection

# @return [Numeric]
attr_reader :poll_interval

#
# Performs the initial load synchronously, so overrides present in the files are in effect
# when this method returns, then starts change detection. A file that does not exist yet
# contributes no overrides. A file that cannot be read or parsed is not fatal: the client
# runs with the last good overrides, the failure is logged, and the retry plus the change
# signal recover once the file is readable.
#
# (see LaunchDarkly::Interfaces::Overrides::OverrideSource#start)
#
def start(sink)
@lock.synchronize do
return if @stopped

@reloader = FileData::Reloader.new(
paths: @paths,
logger: @logger,
log_prefix: LOG_PREFIX,
duplicate_keys_handling: @duplicate_keys_handling,
skip_missing_paths: true,
skip_unchanged: true,
apply: lambda do |merged|
sink.set_overrides(merged.flags.values, merged.segments.values)
log_overrides_in_effect(merged)
end
)
end

@reloader.reload_now

@lock.synchronize do
return if @stopped

trigger = @reloader.method(:trigger)
@change_detector =
if @change_detection == :watching
FileData::Watcher.new(@paths, trigger, @logger)
else
FileData::Poller.new(@paths, @poll_interval, trigger, @logger)
end
end
end

# (see LaunchDarkly::Interfaces::Overrides::OverrideSource#stop)
def stop
reloader = nil
change_detector = nil
@lock.synchronize do
@stopped = true
reloader = @reloader
change_detector = @change_detector
@reloader = nil
@change_detector = nil
end
change_detector&.stop
reloader&.stop
end

#
# Reports the overrides now in effect and the file each came from. The reloader applies a
# snapshot only when the content changed, so this logs each change once.
#
# @param merged [LaunchDarkly::Impl::FileData::MergeResult]
#
private def log_overrides_in_effect(merged)
details = merged.files.map do |file|
if !file.present
"#{file.path}: absent"
elsif file.flags.zero? && file.segments.zero?
"#{file.path}: no entries"
else
"#{file.path}: #{counts_text(file.flags, file.segments)}"
end
end.join("; ")

if merged.empty?
@logger.info { "#{LOG_PREFIX} Flag overrides: none in effect (#{details})" }
else
@logger.info do
"#{LOG_PREFIX} Flag overrides in effect: #{counts_text(merged.flags.length, merged.segments.length)} (#{details})"
end
end
end

# Formats flag and segment counts, for example "2 flags, 1 segment".
private def counts_text(flags, segments)
parts = []
parts << pluralize(flags, "flag") if flags > 0
parts << pluralize(segments, "segment") if segments > 0
parts.join(", ")
end

private def pluralize(count, noun)
count == 1 ? "1 #{noun}" : "#{count} #{noun}s"
end
end
end
end
end
Loading
Loading