slang 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +28 -0
- data/LICENSE.md +20 -0
- data/README.md +242 -0
- data/Rakefile +12 -0
- data/lib/slang.rb +144 -0
- data/lib/slang/internal.rb +139 -0
- data/lib/slang/railtie.rb +16 -0
- data/lib/slang/snapshot.rb +131 -0
- data/lib/slang/snapshot/locale.rb +35 -0
- data/lib/slang/snapshot/rules.rb +239 -0
- data/lib/slang/snapshot/template.rb +58 -0
- data/lib/slang/snapshot/translation.rb +135 -0
- data/lib/slang/snapshot/warnings.rb +102 -0
- data/lib/slang/updater/abstract.rb +74 -0
- data/lib/slang/updater/development.rb +88 -0
- data/lib/slang/updater/http_helpers.rb +49 -0
- data/lib/slang/updater/key_reporter.rb +92 -0
- data/lib/slang/updater/production.rb +218 -0
- data/lib/slang/updater/shared_state.rb +59 -0
- data/lib/slang/updater/squelchable.rb +45 -0
- data/lib/slang/version.rb +3 -0
- data/slang.gemspec +20 -0
- data/test/data/snapshot.json +64 -0
- data/test/helper.rb +4 -0
- data/test/test_locale.rb +47 -0
- data/test/test_rules.rb +133 -0
- data/test/test_snapshot.rb +132 -0
- data/test/test_template.rb +49 -0
- data/test/test_translation.rb +94 -0
- data/test/test_warnings.rb +123 -0
- metadata +99 -0
| @@ -0,0 +1,49 @@ | |
| 1 | 
            +
            module Slang
         | 
| 2 | 
            +
              module Updater
         | 
| 3 | 
            +
             | 
| 4 | 
            +
                module HTTPHelpers
         | 
| 5 | 
            +
                  CHILLAX_RETRY    = 1
         | 
| 6 | 
            +
                  GONE_RETRY       = 7200
         | 
| 7 | 
            +
                  TEMPORARY_RETRY  = 60
         | 
| 8 | 
            +
                  UNEXPECTED_RETRY = 600
         | 
| 9 | 
            +
                  UNKNOWN_RETRY    = 120
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  def get(uri, add_headers={})
         | 
| 12 | 
            +
                    request = Net::HTTP::Get.new(uri)
         | 
| 13 | 
            +
                    perform_request(request, add_headers)
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def post(uri, body, add_headers={})
         | 
| 17 | 
            +
                    request = Net::HTTP::Post.new(uri)
         | 
| 18 | 
            +
                    request.body = body
         | 
| 19 | 
            +
                    perform_request(request, add_headers)
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                  def perform_request(request, add_headers)
         | 
| 23 | 
            +
                    add_headers["User-Agent"] = Slang::USER_AGENT
         | 
| 24 | 
            +
                    add_headers["Accept-Encoding"] = "gzip" # Akamai doesn't handle Ruby's default Accept-Encoding with q-values
         | 
| 25 | 
            +
                    add_headers.each { |name, value| request[name] = value }
         | 
| 26 | 
            +
                    begin
         | 
| 27 | 
            +
                      uri = request.uri
         | 
| 28 | 
            +
                      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: (uri.scheme == "https")) do |http|
         | 
| 29 | 
            +
                        http.request(request)
         | 
| 30 | 
            +
                      end
         | 
| 31 | 
            +
                      response.body = gunzip(response.body) if response["Content-Encoding"] == "gzip" # must uncompress ourselves
         | 
| 32 | 
            +
                      response
         | 
| 33 | 
            +
                    rescue SystemCallError => e
         | 
| 34 | 
            +
                      raise NetworkError.new(TEMPORARY_RETRY), e.message
         | 
| 35 | 
            +
                    end
         | 
| 36 | 
            +
                  end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                  def gunzip(data)
         | 
| 39 | 
            +
                    io = StringIO.new(data)
         | 
| 40 | 
            +
                    gz = Zlib::GzipReader.new(io)
         | 
| 41 | 
            +
                    uncompressed = gz.read
         | 
| 42 | 
            +
                    gz.close
         | 
| 43 | 
            +
                    uncompressed
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                end # module
         | 
| 47 | 
            +
             | 
| 48 | 
            +
              end
         | 
| 49 | 
            +
            end
         | 
| @@ -0,0 +1,92 @@ | |
| 1 | 
            +
            require "slang/updater/http_helpers"
         | 
| 2 | 
            +
            require "slang/updater/squelchable"
         | 
| 3 | 
            +
            module Slang
         | 
| 4 | 
            +
              module Updater
         | 
| 5 | 
            +
             | 
| 6 | 
            +
                class KeyReporter
         | 
| 7 | 
            +
                  include HTTPHelpers
         | 
| 8 | 
            +
                  include Squelchable
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  attr_reader :uri
         | 
| 11 | 
            +
                  attr_reader :dev_key
         | 
| 12 | 
            +
                  attr_reader :report_interval
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  # Create a key reporter.
         | 
| 15 | 
            +
                  #
         | 
| 16 | 
            +
                  # @param [String]  key reporter endpoint
         | 
| 17 | 
            +
                  # @param [String]  developer key
         | 
| 18 | 
            +
                  # @param [Numeric] report interval
         | 
| 19 | 
            +
                  #
         | 
| 20 | 
            +
                  def initialize(url, dev_key, report_interval=10)
         | 
| 21 | 
            +
                    @uri = URI(url)
         | 
| 22 | 
            +
                    @dev_key = dev_key
         | 
| 23 | 
            +
                    @report_interval = report_interval
         | 
| 24 | 
            +
                    @pending = {}
         | 
| 25 | 
            +
                    @sent = Set.new
         | 
| 26 | 
            +
                    @lock = Mutex.new
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  # Report unknown key.
         | 
| 30 | 
            +
                  #
         | 
| 31 | 
            +
                  # @param [String] the unknown key
         | 
| 32 | 
            +
                  # @param [Hash]   variable map passed (may be empty)
         | 
| 33 | 
            +
                  #
         | 
| 34 | 
            +
                  def unknown_key(key, variable_map)
         | 
| 35 | 
            +
                    var_names = variable_map.keys.sort!
         | 
| 36 | 
            +
                    key_info = var_names.unshift(key)
         | 
| 37 | 
            +
                    key_id = key_info.join("|").to_sym
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                    pid = Process.pid
         | 
| 40 | 
            +
                    @lock.synchronize do
         | 
| 41 | 
            +
                      unless @pid == pid
         | 
| 42 | 
            +
                        @pid = pid
         | 
| 43 | 
            +
                        @thread = Thread.new { loop { run_loop } }
         | 
| 44 | 
            +
                        Slang.log_info("Started background key reporter thread (pid=#{pid}).")
         | 
| 45 | 
            +
                      end
         | 
| 46 | 
            +
                      unless @sent.include?(key_id)
         | 
| 47 | 
            +
                        @pending[key_id] = key_info
         | 
| 48 | 
            +
                      end
         | 
| 49 | 
            +
                    end
         | 
| 50 | 
            +
                  end
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                private
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  def run_loop
         | 
| 55 | 
            +
                    squelch_thread_activity
         | 
| 56 | 
            +
                    begin
         | 
| 57 | 
            +
                      check_for_pending_keys
         | 
| 58 | 
            +
                      squelch!(report_interval)
         | 
| 59 | 
            +
                    rescue NetworkError => e
         | 
| 60 | 
            +
                      squelch!(e.retry_in, "network failure - #{e.message}")
         | 
| 61 | 
            +
                    rescue => e
         | 
| 62 | 
            +
                      squelch!(nil, "unexpected error - #{e.message}")
         | 
| 63 | 
            +
                      Slang.log_error(e.backtrace.join("\n")) unless SlangError === e
         | 
| 64 | 
            +
                    end
         | 
| 65 | 
            +
                  end
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  def check_for_pending_keys
         | 
| 68 | 
            +
                    report_pending = @lock.synchronize { @pending.dup }
         | 
| 69 | 
            +
                    return if report_pending.empty?
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                    values = report_pending.values # key, [var1], ..., [varN]
         | 
| 72 | 
            +
                    Slang.log_info("Reporting new key(s): #{values.map { |k| k.first }.join(", ")}")
         | 
| 73 | 
            +
                    json = Oj.dump(values, mode: :compat)
         | 
| 74 | 
            +
                    response = post(uri, json, "Content-Type" => "application/json", "X-Slang-Dev-Key" => dev_key)
         | 
| 75 | 
            +
                    case response
         | 
| 76 | 
            +
                    when Net::HTTPSuccess #2xx
         | 
| 77 | 
            +
                      @lock.synchronize do
         | 
| 78 | 
            +
                        report_pending.each_key do |key_id|
         | 
| 79 | 
            +
                          @sent << key_id
         | 
| 80 | 
            +
                          @pending.delete(key_id)
         | 
| 81 | 
            +
                        end
         | 
| 82 | 
            +
                      end
         | 
| 83 | 
            +
                    when Net::HTTPServerError # 5xx
         | 
| 84 | 
            +
                      raise NetworkError.new(TEMPORARY_ERROR), "HTTP #{response.code}"
         | 
| 85 | 
            +
                    else
         | 
| 86 | 
            +
                      raise NetworkError.new(nil), "HTTP #{response.code}"
         | 
| 87 | 
            +
                    end
         | 
| 88 | 
            +
                  end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                end # class
         | 
| 91 | 
            +
              end
         | 
| 92 | 
            +
            end
         | 
| @@ -0,0 +1,218 @@ | |
| 1 | 
            +
            require "slang/updater/abstract"
         | 
| 2 | 
            +
            module Slang
         | 
| 3 | 
            +
              module Updater
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                # The production updater is responsible for periodically checking for new snapshots and activating them. State and
         | 
| 6 | 
            +
                # snapshots are persisted to permanent storage in the Slang data directory. Multiple Ruby processes in the same
         | 
| 7 | 
            +
                # Slang project can (and should) share the same Slang data directory to avoid redundant update checks.
         | 
| 8 | 
            +
                #
         | 
| 9 | 
            +
                class Production < Abstract
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  # Return the latest snapshot. Thread-safe.
         | 
| 12 | 
            +
                  #
         | 
| 13 | 
            +
                  def snapshot
         | 
| 14 | 
            +
                    pid = Process.pid
         | 
| 15 | 
            +
                    lock.synchronize do
         | 
| 16 | 
            +
                      unless @pid == pid
         | 
| 17 | 
            +
                        @pid = pid
         | 
| 18 | 
            +
                        @thread = Thread.new { loop { run_loop } }
         | 
| 19 | 
            +
                        Slang.log_info("Started background update thread (pid=#{pid}).")
         | 
| 20 | 
            +
                      end
         | 
| 21 | 
            +
                      @snapshot
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                private
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def run_loop
         | 
| 28 | 
            +
                    begin
         | 
| 29 | 
            +
                      sleep_for = shared_state.exclusive_lock do |state|
         | 
| 30 | 
            +
                        (state[:snapshot_id] == @snapshot.id) ? (state[:check_at].to_f - Time.now.to_f) : 0
         | 
| 31 | 
            +
                      end
         | 
| 32 | 
            +
                      if sleep_for > 0
         | 
| 33 | 
            +
                        sleep_for += rand
         | 
| 34 | 
            +
                        Slang.log_debug("Resting for a bit ~#{sleep_for.round(2)}s.")
         | 
| 35 | 
            +
                        sleep(sleep_for)
         | 
| 36 | 
            +
                        return
         | 
| 37 | 
            +
                      end
         | 
| 38 | 
            +
                      Slang.log_debug("Huh? I'm awake!")
         | 
| 39 | 
            +
                      check_for_new_snapshot
         | 
| 40 | 
            +
                    rescue => e
         | 
| 41 | 
            +
                      Slang.log_warn("Snapshot update failed: #{e.message}")
         | 
| 42 | 
            +
                      Slang.log_warn(e.backtrace.join("\n")) unless SlangError === e
         | 
| 43 | 
            +
                      shared_state.exclusive_lock do |state|
         | 
| 44 | 
            +
                        state.delete(:checking_pid) if state[:checking_pid] == @pid
         | 
| 45 | 
            +
                        state[:check_at] = Time.now.to_f + UNEXPECTED_RETRY
         | 
| 46 | 
            +
                      end
         | 
| 47 | 
            +
                      sleep(UNEXPECTED_RETRY)
         | 
| 48 | 
            +
                    end
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def check_for_new_snapshot
         | 
| 52 | 
            +
                    action, arg = shared_state.exclusive_lock do |state|
         | 
| 53 | 
            +
                      if state[:snapshot_id] != @snapshot.id
         | 
| 54 | 
            +
                        [ :activate_snapshot, state[:snapshot_id] ]
         | 
| 55 | 
            +
                      elsif state[:checking_pid]
         | 
| 56 | 
            +
                        alive = Process.getpgid(state[:checking_pid]) rescue nil
         | 
| 57 | 
            +
                        if alive
         | 
| 58 | 
            +
                          [ :checker_alive, state[:checking_pid] ]
         | 
| 59 | 
            +
                        else
         | 
| 60 | 
            +
                          state[:checking_pid] = @pid
         | 
| 61 | 
            +
                          [ :checker_dead, state[:modified_at] ]
         | 
| 62 | 
            +
                        end
         | 
| 63 | 
            +
                      else
         | 
| 64 | 
            +
                        state[:checking_pid] = @pid
         | 
| 65 | 
            +
                        [ :check, state[:modified_at] ]
         | 
| 66 | 
            +
                      end
         | 
| 67 | 
            +
                    end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    case action
         | 
| 70 | 
            +
                    when :activate_snapshot
         | 
| 71 | 
            +
                      Slang.log_debug("Sweet. Found new snapshot.")
         | 
| 72 | 
            +
                      snapshot_path = data_file_path(arg)
         | 
| 73 | 
            +
                      new_snapshot = Snapshot.from_json(File.read(snapshot_path))
         | 
| 74 | 
            +
                      activate(new_snapshot)
         | 
| 75 | 
            +
                      return
         | 
| 76 | 
            +
                    when :checker_alive
         | 
| 77 | 
            +
                      Slang.log_debug("Chillin' like a villian (waiting on PID #{arg}).")
         | 
| 78 | 
            +
                      sleep(CHILLAX_RETRY)
         | 
| 79 | 
            +
                      return
         | 
| 80 | 
            +
                    when :checker_dead
         | 
| 81 | 
            +
                      Slang.log_debug("Checking worker is dead. RIP. My turn.")
         | 
| 82 | 
            +
                    else # :check
         | 
| 83 | 
            +
                      Slang.log_debug("Looking for a new snapshot.")
         | 
| 84 | 
            +
                    end
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                    begin
         | 
| 87 | 
            +
                      modified_at, check_in = fetch_meta(arg)
         | 
| 88 | 
            +
                      shared_state.exclusive_lock do |state|
         | 
| 89 | 
            +
                        state.delete(:checking_pid)
         | 
| 90 | 
            +
                        state[:check_at] = Time.now.to_f + check_in
         | 
| 91 | 
            +
                        state[:modified_at] = modified_at
         | 
| 92 | 
            +
                      end
         | 
| 93 | 
            +
                      sleep(check_in)
         | 
| 94 | 
            +
                    rescue NetworkError => e
         | 
| 95 | 
            +
                      shared_state.exclusive_lock do |state|
         | 
| 96 | 
            +
                        state.delete(:checking_pid)
         | 
| 97 | 
            +
                        state[:check_at] = Time.now.to_f + e.retry_in
         | 
| 98 | 
            +
                      end
         | 
| 99 | 
            +
                      sleep(e.retry_in)
         | 
| 100 | 
            +
                    end
         | 
| 101 | 
            +
                  end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                  def fetch_meta(modified_at)
         | 
| 104 | 
            +
                    Slang.log_debug("Fetching meta.")
         | 
| 105 | 
            +
                    response = get(uri, "If-Modified-Since" => Time.at(modified_at).httpdate)
         | 
| 106 | 
            +
                    case response
         | 
| 107 | 
            +
                    when Net::HTTPNotModified # 304
         | 
| 108 | 
            +
                      Slang.log_info("Snapshot up-to-date.")
         | 
| 109 | 
            +
                    when Net::HTTPOK # 200
         | 
| 110 | 
            +
                      Slang.log_debug("New snapshot? YES!")
         | 
| 111 | 
            +
                      parse_meta(response.body.force_encoding(Encoding::UTF_8))
         | 
| 112 | 
            +
                    when Net::HTTPNotFound # 404
         | 
| 113 | 
            +
                      raise NetworkError.new(UNKNOWN_RETRY), "Unknown project (404)."
         | 
| 114 | 
            +
                    when Net::HTTPGone # 410
         | 
| 115 | 
            +
                      raise NetworkError.new(GONE_RETRY), "Unknown project (410)."
         | 
| 116 | 
            +
                    when Net::HTTPServerError # 5xx
         | 
| 117 | 
            +
                      raise NetworkError.new(TEMPORARY_RETRY), "HTTP #{response.code}"
         | 
| 118 | 
            +
                    else
         | 
| 119 | 
            +
                      raise NetworkError.new(UNEXPECTED_RETRY), "Unexpected response (HTTP #{response.code})"
         | 
| 120 | 
            +
                    end
         | 
| 121 | 
            +
                    last_modified = Time.httpdate(response["Last-Modified"]) rescue Time.now.utc
         | 
| 122 | 
            +
                    max_age = response["Cache-Control"] =~ /max-age=(\d+)/ ? [$1.to_i, 10].max : UNEXPECTED_RETRY
         | 
| 123 | 
            +
                    [ last_modified.to_i, max_age ]
         | 
| 124 | 
            +
                  end
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                  def parse_meta(json)
         | 
| 127 | 
            +
                    meta = Oj.strict_load(json, symbol_keys: true)
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                    if meta[:id] == @snapshot.id
         | 
| 130 | 
            +
                      Slang.log_debug("Err.. Nope. Already at this snapshot - #{@snapshot.id}")
         | 
| 131 | 
            +
                      return
         | 
| 132 | 
            +
                    end
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                    delta_url = meta[:deltas].each_slice(2) do |snapshot_id, url|
         | 
| 135 | 
            +
                      break(url) if snapshot_id == @snapshot.id
         | 
| 136 | 
            +
                    end
         | 
| 137 | 
            +
                    if delta_url
         | 
| 138 | 
            +
                      Slang.log_debug("Delta-delta-delta. How can I help-ya? #{delta_url}")
         | 
| 139 | 
            +
                      begin
         | 
| 140 | 
            +
                        snapshot_json = patch_snapshot(get_asset(delta_url))
         | 
| 141 | 
            +
                      rescue => e
         | 
| 142 | 
            +
                        Slang.log_warn("Unexpected error fetching/applying delta, trying snapshot - #{e.message}")
         | 
| 143 | 
            +
                      end
         | 
| 144 | 
            +
                    end
         | 
| 145 | 
            +
                    snapshot_json ||= get_asset(meta[:url])
         | 
| 146 | 
            +
                    persist_new_snapshot(snapshot_json)
         | 
| 147 | 
            +
                  end
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                  def get_asset(url)
         | 
| 150 | 
            +
                    response = get(URI(url))
         | 
| 151 | 
            +
                    case response
         | 
| 152 | 
            +
                    when Net::HTTPOK # 200
         | 
| 153 | 
            +
                      Slang.log_debug("Asset retrieved.")
         | 
| 154 | 
            +
                      response.body.force_encoding(Encoding::UTF_8)
         | 
| 155 | 
            +
                    when Net::HTTPNotFound # 404
         | 
| 156 | 
            +
                      raise NetworkError.new(UNKNOWN_RETRY), "Unknown asset (404)."
         | 
| 157 | 
            +
                    when Net::HTTPGone # 410
         | 
| 158 | 
            +
                      raise NetworkError.new(GONE_RETRY), "Unknown asset (410)."
         | 
| 159 | 
            +
                    when Net::HTTPServerError # 5xx
         | 
| 160 | 
            +
                      raise NetworkError.new(TEMPORARY_RETRY), "HTTP #{response.code}"
         | 
| 161 | 
            +
                    else
         | 
| 162 | 
            +
                      raise NetworkError.new(UNEXPECTED_RETRY), "Unexpected response (HTTP #{response.code})"
         | 
| 163 | 
            +
                    end
         | 
| 164 | 
            +
                  end
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                  def persist_new_snapshot(json)
         | 
| 167 | 
            +
                    new_snapshot = Snapshot.from_json(json)
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                    File.write(data_file_path(new_snapshot.id), json)
         | 
| 170 | 
            +
                    shared_state.exclusive_lock { |state| state[:snapshot_id] = new_snapshot.id }
         | 
| 171 | 
            +
                    FileUtils.rm(data_file_path(@snapshot.id), force: true)
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                    activate(new_snapshot) # last
         | 
| 174 | 
            +
                  end
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                  def patch_snapshot(delta_json)
         | 
| 177 | 
            +
                    snapshot_json = File.read(data_file_path(@snapshot.id))
         | 
| 178 | 
            +
                    snapshot_array = Snapshot.array_from_json(snapshot_json)
         | 
| 179 | 
            +
                    delta_array = Oj.strict_load(delta_json)
         | 
| 180 | 
            +
             | 
| 181 | 
            +
                    delta_format, snapshot_id, timestamp, default_locale_code, locales_diff, drop, add, change = *delta_array
         | 
| 182 | 
            +
                    raise UpdaterError, "unknown snapshot delta format - #{delta_format}" unless delta_format == 6
         | 
| 183 | 
            +
                    locales = patch(snapshot_array[4], locales_diff)
         | 
| 184 | 
            +
                    translations = snapshot_array[5]
         | 
| 185 | 
            +
                    drop.each { |i| translations.delete_at(i) }
         | 
| 186 | 
            +
                    add.each do |array|
         | 
| 187 | 
            +
                      i = array.shift
         | 
| 188 | 
            +
                      translations.insert(i, array)
         | 
| 189 | 
            +
                    end
         | 
| 190 | 
            +
                    change.each do |array|
         | 
| 191 | 
            +
                      i = array.shift
         | 
| 192 | 
            +
                      translations[i] = patch(translations[i], array)
         | 
| 193 | 
            +
                    end
         | 
| 194 | 
            +
                    new_snapshot_array = [ delta_format, snapshot_id, timestamp, default_locale_code, locales, translations ]
         | 
| 195 | 
            +
                    Oj.dump(new_snapshot_array, mode: :strict)
         | 
| 196 | 
            +
                  end
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                  def patch(object, transforms)
         | 
| 199 | 
            +
                    return object if transforms.empty?
         | 
| 200 | 
            +
                    data = Oj.dump(object, mode: :strict).force_encoding(Encoding::BINARY)
         | 
| 201 | 
            +
                    i = 0
         | 
| 202 | 
            +
                    transforms.each do |x|
         | 
| 203 | 
            +
                      if String === x
         | 
| 204 | 
            +
                        x.force_encoding(Encoding::BINARY)
         | 
| 205 | 
            +
                        data.insert(i, x)
         | 
| 206 | 
            +
                        i += x.length
         | 
| 207 | 
            +
                      elsif x < 0
         | 
| 208 | 
            +
                        data.slice!(i, -x)
         | 
| 209 | 
            +
                      else
         | 
| 210 | 
            +
                        i += x
         | 
| 211 | 
            +
                      end
         | 
| 212 | 
            +
                    end
         | 
| 213 | 
            +
                    Oj.strict_load(data.force_encoding(Encoding::UTF_8))
         | 
| 214 | 
            +
                  end
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                end
         | 
| 217 | 
            +
              end
         | 
| 218 | 
            +
            end
         | 
| @@ -0,0 +1,59 @@ | |
| 1 | 
            +
            module Slang
         | 
| 2 | 
            +
              module Updater
         | 
| 3 | 
            +
             | 
| 4 | 
            +
                # A multi-process-safe shared state file. Uses JSON for serialization, and exclusive file locking.
         | 
| 5 | 
            +
                #
         | 
| 6 | 
            +
                class SharedState
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  attr_reader :path
         | 
| 9 | 
            +
                  attr_reader :permissions
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  # Create a SharedState instance. State file will be created as necessary.
         | 
| 12 | 
            +
                  #
         | 
| 13 | 
            +
                  # @param [String] pathname of stale file.
         | 
| 14 | 
            +
                  # @param [Fixnum] permissions, defaults to 0644.
         | 
| 15 | 
            +
                  #
         | 
| 16 | 
            +
                  def initialize(path, permissions = 0644)
         | 
| 17 | 
            +
                    @path = File.expand_path(path).freeze
         | 
| 18 | 
            +
                    @permissions = permissions
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  # This method opens the shared state file, obtains an exclusive lock, and reads/deserializes its content. A
         | 
| 22 | 
            +
                  # state hash (possibly empty) is yielded to the block, *while* the exclusive lock is held. The block may mutate
         | 
| 23 | 
            +
                  # the state hash as necessary. When the yield returns, any changes made to the state are serialized and written
         | 
| 24 | 
            +
                  # out the the file. Finally, the file is closed and lock released.
         | 
| 25 | 
            +
                  #
         | 
| 26 | 
            +
                  # @yield [Hash] the current state (keys are symbols).
         | 
| 27 | 
            +
                  # @return result of yielded block
         | 
| 28 | 
            +
                  #
         | 
| 29 | 
            +
                  def exclusive_lock
         | 
| 30 | 
            +
                    File.open(path, File::RDWR | File::CREAT, permissions) do |f|
         | 
| 31 | 
            +
                      f.flock(File::LOCK_EX)
         | 
| 32 | 
            +
                      state = deserialize(f.read)
         | 
| 33 | 
            +
                      state.each_value { |v| v.freeze }
         | 
| 34 | 
            +
                      original_state = state.dup
         | 
| 35 | 
            +
                      ret = yield(state)
         | 
| 36 | 
            +
                      unless original_state == state
         | 
| 37 | 
            +
                        f.rewind
         | 
| 38 | 
            +
                        f.write(serialize(state))
         | 
| 39 | 
            +
                        f.flush
         | 
| 40 | 
            +
                        f.truncate(f.pos)
         | 
| 41 | 
            +
                      end
         | 
| 42 | 
            +
                      ret
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                private
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  def deserialize(json)
         | 
| 49 | 
            +
                    return {} if json.empty?
         | 
| 50 | 
            +
                    Oj.strict_load(json.force_encoding(Encoding::UTF_8), symbol_keys: true)
         | 
| 51 | 
            +
                  end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  def serialize(obj)
         | 
| 54 | 
            +
                    Oj.dump(obj, mode: :compat)
         | 
| 55 | 
            +
                  end
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                end # class
         | 
| 58 | 
            +
              end
         | 
| 59 | 
            +
            end
         | 
| @@ -0,0 +1,45 @@ | |
| 1 | 
            +
            module Slang
         | 
| 2 | 
            +
              module Updater
         | 
| 3 | 
            +
             | 
| 4 | 
            +
                # This module adds the ability to squelch activity for a temporary amount of time (or permanently).
         | 
| 5 | 
            +
                #
         | 
| 6 | 
            +
                module Squelchable
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def squelch!(interval, reason=nil)
         | 
| 9 | 
            +
                    if interval
         | 
| 10 | 
            +
                      @squelch_until = Time.now + interval
         | 
| 11 | 
            +
                      Slang.log_warn("Slang activity temporarily disabled for ~#{interval}s. #{reason}") if reason
         | 
| 12 | 
            +
                    else
         | 
| 13 | 
            +
                      @squelch_until = :forever
         | 
| 14 | 
            +
                      Slang.log_error("Slang activity permanently disabled - #{reason}")
         | 
| 15 | 
            +
                    end
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def squelched?
         | 
| 19 | 
            +
                    if @squelch_until
         | 
| 20 | 
            +
                      if @squelch_until == :forever
         | 
| 21 | 
            +
                        :forever
         | 
| 22 | 
            +
                      else
         | 
| 23 | 
            +
                        wait = @squelch_until - Time.now
         | 
| 24 | 
            +
                        if wait > 0
         | 
| 25 | 
            +
                          wait
         | 
| 26 | 
            +
                        else
         | 
| 27 | 
            +
                          @squelch_until = nil
         | 
| 28 | 
            +
                        end
         | 
| 29 | 
            +
                      end
         | 
| 30 | 
            +
                    end
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def squelch_thread_activity
         | 
| 34 | 
            +
                    wait = squelched?
         | 
| 35 | 
            +
                    case wait
         | 
| 36 | 
            +
                    when nil;      # continue
         | 
| 37 | 
            +
                    when :forever; loop { Thread.stop }
         | 
| 38 | 
            +
                    else           sleep(wait)
         | 
| 39 | 
            +
                    end
         | 
| 40 | 
            +
                  end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                end # module
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              end
         | 
| 45 | 
            +
            end
         |