lib/universa/client.rb in universa-0.2.6 vs lib/universa/client.rb in universa-0.3.1
- old
+ new
@@ -2,199 +2,152 @@
require 'open-uri'
require 'concurrent'
module Universa
- using Universa
+ # The low-level adapter for the UMI Universa client. We provide convenience wrappers for it
+ # {Client} and {Connection} classes, more rubyish in interface paradigm, so there is no need to use it directly.
+ class UmiClient < RemoteAdapter
+ remote_class "com.icodici.universa.node2.network.Client"
+ end
- # Universa network client reads current network configuration and provides access to each node independently
- # and also implement newtor-wide procedures.
+
+ # The universa network client. Discover and connects to the universa network, provides consensus operations
+ # and all other whole-network related functions.
class Client
using Universa::Parallel
include Universa
- attr :connection_key
+ # Discovered network size
+ attr :size
- # Create client
- # @param [PrivateKey] private_key to connect with. Generates new one if omitted.
- def initialize private_key = nil
- @connection_key = private_key
- scan_network()
- end
+ # Client private key ised in the connection
+ attr :private_key
- # Number of accessible nodes
- def size
- @nodes.size
+ # Construct an Universa network client. Bu default, connects to the main network. Perform consensus-based
+ # network scanning and saves the current network topology in the cache on the file system, default is under
+ # +~/.universa+ but could be overriden.
+ #
+ # If the network topology file is presented but the cached topology is newer, the cached will be used.
+ #
+ # The client accepts small network topology changes as long as it still create consensus. Still, too big changes
+ # in the network topology might require fresh topology file (or upgrade the gem).
+ #
+ #
+ # @param [String] topology: could be name of known network (e.g. mainnet as by default) or path to a .json file
+ # containing some network topology, for example, obtained from some external source like telegram
+ # channel.
+ # @param [PrivateKey] private_key to connect with.
+ # @param [String] cache_dir where to store resulting topology. we recommend to leave it as nil.
+ #
+ # @raise if network topology could not be checked/obtained.
+ def initialize topology: "mainnet", private_key: PrivateKey.new(2048), cache_dir: nil
+ @client = UmiClient.new topology, cache_dir, private_key
+ @private_key = PrivateKey
+ @size = @client.size
+ @connections = (0...@size).map {nil}
end
- # private key used by the connection (might be generated)
- def private_key
- @connection_key ||= PrivateKey.new(2048)
+ # Get the node connection by its index (0...size).
+ # @return [Connection] object
+ def [] index
+ raise IndexError if index < 0 || index >= @size
+ @connections[index] ||= Connection.new(@client.getClient(index))
end
- # @return [Connection] random connection
+ # Get the random node connection
+ # @return [Connection] node connection
def random_connection
- @nodes.sample
+ self[rand(0...size)]
end
- def register_single contract
- random_connection.register_single contract
+ # Get several random connections
+ # @param [Numeric] number of connections to get
+ # @return [Array(Connection)] array of connections to random (non repeating) nodes
+ def random_connections number
+ (0...size).to_a.sample(number).map {|n| self[n]}
end
- # Perform fats consensus state check. E.g. it scans up to 2/3 of the network until
- # the positive or negative consensus will be found. So far you can only rely on
- # result.approved? as it returns some last node result which, though, match the
- # consensus. Aggregation of parameters is under way.
+ # Perform fast consensus state check with a given trust level, as the fraction of the whole network size.
+ # It checks the network nodes randomly until get enough positive or negative states. The lover the required
+ # trust level is, the faster the answer will be found.
#
- # @param [Contract | HashId] obj to check
- # @return [ContractState] of some final node check It does not aggregates (yet)
- def get_state obj
+ # @param [Contract | HashId] obj contract to check
+ # @param [Object] trust level, should be between 0.1 (10% of network) and 0.9 (90% of the network)
+ # @return [ContractState] of some final node check It does not calculates average time (yet)
+ def get_state obj, trust: 0.3
+ raise ArgumentError, "trusst must be in 0.1..0.9 range" if trust < 0.1 || trust > 0.9
result = Concurrent::IVar.new
- negative_votes = Concurrent::AtomicFixnum.new(@nodes.size * 11 / 100)
- positive_votes = Concurrent::AtomicFixnum.new(@nodes.size * 30 / 100)
- retry_with_timeout(20, 3) {
- random_connections(@nodes.size).par.each {|conn|
+ negative_votes = Concurrent::AtomicFixnum.new((size * 0.1).round + 1)
+ positive_votes = Concurrent::AtomicFixnum.new((size * trust).round)
+
+ # consensus-finding conveyor: we chek connections in batches in parallel until get
+ # some consensus. We do not wait until all of them will answer
+ (0...size).to_a.shuffle.each {|index|
+ Thread.start {
if result.incomplete?
- if (state = conn.get_state(obj)).approved?
+ if (state = self[index].get_state(obj)).approved?
result.try_set(state) if positive_votes.decrement < 0
else
result.try_set(state) if negative_votes.decrement < 0
end
end
}
- result.value
}
+ result.value
end
- # @return [Array(Connection)] array of count randomly selected connections
- def random_connections count = 1
- @nodes.sample(count)
- end
-
- def [] name
- @nodes.find {|x| x.url =~ /#{name}/}
- end
-
- private
-
- # Rescan the network collecting the networ map comparing results from random 70% of nodes.
- def scan_network
- # Todo: cache known nodes
- root_nodes = (1..30).map {|n| "http://node-#{n}-com.utoken.io:8080/network"}
-
- # We scan random 70% for consensus
- n = root_nodes.size * 0.7
-
- candidates = {}
- root_nodes.sample(n).par.each {|path|
- retry_with_timeout(5, 3) {
- SmartHash.new(Boss.unpack open(path).read).response.nodes.each {|data|
- ni = NodeInfo.new(data)
- (candidates[ni] ||= ni).increment_rate
- }
- }
+ # Register a single contract (on private network or if you have white key allowing free operations)
+ # on a random node. Client must check returned contract state. It requires "open" network or special
+ # key that has a right to register contracts without payment.
+ #
+ # When retrying, randpm nodes are selected.
+ #
+ # @param [Contract] contract must be sealed ({Contract#seal})
+ #
+ # @return [ContractState] of the result. Could contain errors.
+ def register_single(contract, timeout: 45, max_retries: 3)
+ retry_with_timeout(timeout, max_retries) {
+ ContractState.new(random_connection.register_single(contract, timeout / max_retries * 1000 - 100))
}
- nodes = candidates.values.group_by(&:url)
- .transform_values!(&:sort)
- # We roughly assume the full network size as:
- network_max_size = nodes.size
- # Refine result: takes most voted nodes and only these with 80% consensus
- # and map it to Connection objects
- min_rate = n * 0.8
- @nodes = nodes.values.map {|v| v[-1]}.delete_if {|v| v.rate < min_rate}
- .map {|ni| Connection.new(self, ni)}
- raise NetworkError, "network is not ready" if @nodes.size < network_max_size * 0.9
end
+
end
- # The node information
- class NodeInfo
- attr :number, :packed_key, :url
+ class Connection
- # constructs from binary packed data
- def initialize(data)
- @data, @number, @url, @packed_key = data, data.number, data.url, data.packed_key
- @rate = Concurrent::AtomicFixnum.new
+ def initialize umi_client
+ @client = umi_client
end
- # currently collected approval rate
- def rate
- @rate.value
+ # Check the connected node is alive. It is adivesd to call {restart} on nodes that return false on pings
+ # to reestablish connection.
+ #
+ # @return true if it is ok
+ def ping
+ @client.ping
end
- # increase approval rate
- def increment_rate
- @rate.increment
+ # Attempt to reestablish connection to the node
+ def restart
+ @client.restart
end
- # check information euqlity
- def == other
- # number == other.number && packed_key == other.packed_key && url == other.url
- url == other&.url && packed_key == other&.packed_key && url == other&.url
+ # node url (IP-based)
+ def url
+ @url ||= @client.get_url
end
- # allows to use as hash key
- def hash
- @url.hash + @packed_key.hash
- end
-
- # to use as hash key
- def eql?(other)
- self == other
- end
-
- # ordered by approval rate
- def < other
- rate < other.rate
- end
-
- def name
- @name ||= begin
- url =~ /^https{0,1}:\/\/([^:]*)/
- $1
- end
- end
- end
-
-
- # Access to the single node using universa client protocol.
- #
- class Connection
- include Universa
-
- # create connection for a given clietn. Don't call it direcly, use
- # {Client.random_connection} or {Client.random_connections} instead. The client implements
- # lazy initialization so time-consuming actual connection will be postponed until
- # needed.
- #
- # @param [Client] client instance to be bound to
- # @param [NodeInfo] node_info to connect to
- def initialize(client, node_info)
- @client, @node_info = client, node_info
- end
-
- # executes ping. Just to ensure connection is alive. Node answers 'sping' => 'spong' hash.
- # 's' states that secure layer of client protocol is used, e.g. with mutual identification and
- # ciphering.
- def ping
- execute(:sping)
- end
-
# Register a single contract (on private network or if you have white key allowing free operations)
- # on a single node.
+ # with the current node. Client must check returned contract state. It requires "open" network or special
+ # key that has a right to register contracts without payment.
#
# @param [Contract] contract must be sealed ({Contract#seal})
+ #
# @return [ContractState] of the result. Could contain errors.
- def register_single(contract)
- retry_with_timeout(15, 3) {
- result = ContractState.new(execute "approve", packedItem: contract.packed)
- while result.is_pending
- sleep(0.1)
- result = get_state contract
- end
- result
- }
+ def register_single(contract, timeout = 25)
+ ContractState.new(@client.register(contract.packed, timeout * 1000))
end
# Get contract or hashId state from this single node
# @param [Contract | HashId] x what to check
# @return [ContractState]
@@ -205,101 +158,75 @@
when Contract
x.hash_id
else
raise ArgumentError, "bad argument, want Contract or HashId"
end
- ContractState.new(execute "getState", itemId: id)
+ ContractState.new(@client.getState(id))
end
-
- # Execute Universa Node client protocol command with optional keyword arguments that will be passed
- # to the node.
- #
- # @param [String|Symbol] name of the command
- # @param kwargs arguments to call
- # @return [SmartHash] with the command result
- def execute(name, **kwargs)
- connection.command name.to_s, *kwargs.to_a.flatten
+ def inspect
+ "<Universa::Connection:#{url}"
end
- # def stats days=0
- # connection.getStats(days.to_i)
- # end
-
- def url
- @node_info.url
- end
-
- def name
- @node_info.name
- end
-
- def number
- @node_info.number
- end
-
def to_s
- "Conn<#{@node_info.url}>"
+ inspect
end
- def inspect
- to_s
- end
-
- protected
-
- def connection
- @connection ||= retry_with_timeout(15, 3) {
- conn = Service.umi.instantiate("com.icodici.universa.node2.network.Client",
- @node_info.url,
- @client.private_key,
- nil,
- false)
- .getClient(@node_info.number - 1)
- conn
- }
- end
-
end
+ # The state of some contract reported by thee network. It is a convenience wrapper around Universa
+ # ItemState structure.
class ContractState
def initialize(universa_contract_state)
@source = universa_contract_state
end
+ # get errors reported by the network
+ # @return [Array(String)] possibly empty array
def errors
- @source.errors&.map &:to_s
+ @_errors ||= @source.errors&.map(&:to_s) || []
rescue
"failed to extract errors: #$!"
end
+ # @return true if the state contain errors
+ def errors?
+ !errors.empty?
+ end
+
+ # @return ItemState structure reported by the UMI
def state
- @source.itemResult.state
+ @source.state
end
+ # Check that state us +PENDING+. Pending state is neither approved nor rejected.
+ # @return true if this state is one of the +PENDING+ states
def is_pending
state.start_with?('PENDING')
end
+ # @return true if the contract state was approved
def is_approved
case state
when 'APPROVED', 'LOCKED'
true
else
false
end
end
+ # same as {is_approved}
def approved?
is_approved
end
+ # same as {pending}
def pending?
is_pending
end
def to_s
- "ContractState:#{state}"
+ "<ContractState:#{state}>"
end
def inspect
to_s
end