zold-io/zold

View on GitHub
lib/zold/commands/merge.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

# Copyright (c) 2018-2023 Zerocracy
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

require 'slop'
require 'rainbow'
require 'backtrace'
require 'shellwords'
require_relative 'thread_badge'
require_relative 'args'
require_relative 'pull'
require_relative '../age'
require_relative '../log'
require_relative '../id'
require_relative '../wallet'
require_relative '../patch'

# MERGE command.
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2018 Yegor Bugayenko
# License:: MIT
module Zold
  # MERGE command
  class Merge
    prepend ThreadBadge

    def initialize(wallets:, remotes:, copies:, log: Log::NULL)
      @wallets = wallets
      @remotes = remotes
      @copies = copies
      @log = log
    end

    # Returns the array of modified wallets (IDs)
    def run(args = [])
      opts = Slop.parse(args, help: true, suppress_errors: true) do |o|
        o.banner = "Usage: zold merge [ID...] [options]
Available options:"
        o.bool '--skip-propagate',
          'Don\'t propagate after merge',
          default: false
        o.bool '--skip-legacy',
          'Don\'t make legacy transactions (older than 24 hours) immutable',
          default: false
        o.bool '--quiet-if-absent',
          'Don\'t fail if the wallet is absent',
          default: false
        o.integer '--depth',
          'How many levels down we try to pull other wallets if their confirmations are required (default: 0)',
          default: 0
        o.bool '--allow-negative-balance',
          'Don\'t check for the negative balance of the wallet after the merge',
          default: false
        o.bool '--no-baseline',
          'Don\'t treat the highest score master copy as trustable baseline',
          default: false
        o.bool '--edge-baseline',
          'Use any strongest group of nodes as baseline, even if there are no masters inside (dangerous!)',
          default: false
        o.string '--ledger',
          'The name of the file where all new negative transactions will be recorded (default: /dev/null)',
          default: '/dev/null'
        o.string '--trusted',
          'The name of the file with a list of wallet IDs we fully trust and won\'t pull',
          default: '/dev/null'
        o.integer '--trusted-max',
          'The maximum amount of trusted wallets we can see in the list',
          default: 128
        o.string '--network',
          'The name of the network we work in',
          default: 'test'
        o.bool '--help', 'Print instructions'
      end
      mine = Args.new(opts, @log).take || return
      modified = []
      list = mine.empty? ? @wallets.all : mine.map { |i| Id.new(i) }
      list.uniq.each do |id|
        next unless merge(id, Copies.new(File.join(@copies, id)), opts)
        modified << id
        next if opts['skip-propagate']
        require_relative 'propagate'
        modified += Propagate.new(wallets: @wallets, log: @log).run(args)
      end
      modified
    end

    private

    def merge(id, cps, opts)
      start = Time.now
      cps = cps.all(masters_first: !opts['edge-baseline'])
      patch = Patch.new(@wallets, log: @log)
      score = 0
      unless opts['skip-legacy']
        @wallets.acq(id) do |w|
          if w.exists?
            s = Time.now
            patch.legacy(w)
            @log.debug("Local copy of #{id} merged legacy in #{Age.new(s)}: #{patch}")
          else
            @log.debug("There is no local copy to merge legacy of #{id}")
          end
        end
      end
      cps.each_with_index do |c, idx|
        wallet = Wallet.new(c[:path])
        baseline = idx.zero? && (c[:master] || opts['edge-baseline']) && !opts['no-baseline']
        name = "#{c[:name]}/#{idx}/#{c[:score]}#{baseline ? '/baseline' : ''}"
        merge_one(opts, patch, wallet, name, baseline: baseline, master: c[:master])
        score += c[:score]
      end
      @wallets.acq(id) do |w|
        if w.exists?
          s = Time.now
          merge_one(opts, patch, w, 'localhost')
          @log.debug("Local copy of #{id} merged in #{Age.new(s)}: #{patch}")
        else
          @log.debug("Local copy of #{id} is absent, nothing to merge")
        end
      end
      if patch.empty?
        return if opts['quiet-if-absent']
        raise "There are no copies of #{id}, nothing to merge"
      end
      modified = @wallets.acq(id, exclusive: true) do |w|
        patch.save(w.path, overwrite: true, allow_negative_balance: opts['allow-negative-balance'])
      end
      if modified
        @log.info("#{cps.count} copies with the total score of #{score} successfully merged \
into #{@wallets.acq(id, &:mnemo)} in #{Age.new(start, limit: 0.1 + (cps.count * 0.01))}")
      else
        @log.info("Nothing changed in #{id} after merge of #{cps.count} copies")
      end
      modified
    end

    def merge_one(opts, patch, wallet, name, baseline: false, master: false)
      start = Time.now
      @log.debug("Adding copy ##{name}#{master ? ' (master)' : ''} to the patch #{wallet.mnemo}...")
      if opts['depth'].positive?
        patch.join(wallet, ledger: opts['ledger'], baseline: baseline, master: master) do |txn|
          trusted = File.read(opts['trusted']).split(',')
          if trusted.include?(txn.bnf.to_s)
            @log.debug("Won't PULL #{txn.bnf} since it is already trusted, among #{trusted.count} others")
          elsif trusted.count > opts['trusted-max']
            @log.debug("Won't PULL #{txn.bnf} since there are too many trusted wallets already: \
#{trusted.count} > #{opts['trusted-max']}")
          else
            File.write(opts['trusted'], (trusted + [txn.bnf.to_s]).sort.uniq.join(','))
            Pull.new(wallets: @wallets, remotes: @remotes, copies: @copies, log: @log).run(
              ['pull', txn.bnf.to_s, "--network=#{Shellwords.escape(opts['network'])}", '--quiet-if-absent'] +
              ["--depth=#{opts['depth'] - 1}"] +
              (opts['no-baseline'] ? ['--no-baseline'] : []) +
              (opts['edge-baseline'] ? ['--edge-baseline'] : []) +
              ["--trusted=#{Shellwords.escape(opts['trusted'])}"]
            )
          end
          true
        end
      else
        patch.join(wallet, ledger: opts['ledger'], baseline: baseline, master: master) do |txn|
          @log.debug("Paying wallet #{txn.bnf} is incomplete but there is not enough depth to PULL: #{txn.to_text}")
          false
        end
      end
      @log.debug("Copy ##{name} of #{wallet.id} merged in #{Age.new(start)}: #{patch}")
    rescue StandardError => e
      @log.error("Can't merge copy #{name}: #{e.message}")
      @log.debug(Backtrace.new(e).to_s)
    end
  end
end