Skip to content
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
56 lines (31 sloc) 8.59 KB Architecture

This document explains some of the main design decisions behind and how the components fit together.

Note that this document assumes some familiarity with the Interledger Protocol (ILP). If you want to read more about the protocol or familiarize yourself with the packet format and flow, please see the Interledger Architecture specification.

Key Design Features:

  • Every ILP packet is processed by a chain of stateless Services
  • All state is kept in an underlying database or Store
  • All details related to an account or peer are bundled in an Account object, which is loaded from the Store and passed through the Services
  • Nothing is instantiated for each packet or for each account; services that behave differently depending on account-specific details or configuration use methods on the Account object to get those details and behave accordingly
  • Multiple identical nodes / connectors can be run and pointed at the same underlying database to horizontally scale a deployment for increased throughput

Services - Core Internal Abstraction

What are Services? is designed around a pattern called services, which is an abstraction used to define clients and servers for request / response protocols. Each service accepts a request and asynchronously returns a successful response or error. Services can be chained together to bundle different types of functionality, add handlers for specific requests, or modify requests before passing them to the next service in the chain. This design is inspired by Tower.

Most components in define either IncomingServices or OutgoingService. An IncomingService accepts an ILP Prepare packet and a "from" Account (representing which account or peer the packet came from) and returns a Future that resolves to either an ILP Fulfill packet or an ILP Reject packet. An OutgoingService is very similar but the request also contains a "to" Account that specifies which account or peer the Prepare packet should be sent to.

Most services implement either the IncomingService or OutgoingService traits and accept a "next" service that implements that same trait. The service will execute its functionality and then may call the next service to pass the request through the chain. A service that handles a given request, such as the IldcpService may return a Fulfill or Reject without calling the next service.

The Router service is unique because it implements the IncomingService trait but accepts an OutgoingService as the next service. It uses the routing table returned by the Store and the destination ILP Address from the Prepare packet to determine the "to" Account the request should be forwarded to. The "to" account may be another intermediary node or the final recipient.

Zero-Copy ILP Packet Forwarding services operate on deserialized ILP Prepare, Fulfill, and Reject packets. However, this implementation uses zero-copy parsing and the "deserialized" ILP packet object contains the serialized packet buffer and simple pointers to the location of each field of the packet. Querying the fields of the packet gives immutable references to the underlying buffer. Additionally, this library enables the two mutable fields in an ILP Prepare packet (amount and expires_at) to be changed in-place so that even a forwarding node / connector does not need to copy the packet.

This approach is more convenient than simply passing a serialized buffer between components (because each one needs access to the deserialized data) and more efficient than copying the fields of the packet into an object only to reserialize them later.

Accounts combines details and configuration related to the customers, peers, and upstream providers of a node into Account records.

Each service can define traits that the Account type passed to it in the IncomingRequests or OutgoingRequests it handles must implement. For example, the HttpClientService requires Accounts to provide get_http_url and get_http_auth_header methods so that the HTTP client knows where to send the outgoing request. A specific Account type can implement any or all of these service-specific traits.

Account details are generally loaded from the Store and passed through the chain of services along with the Prepare packet as part of the request. This gives each service access to the static account-related properties and configuration it needs to apply its functionality without hitting the underlying database for each one. Another nice benefit of this design is that Rust compiler will not allow a Store to be passed to a particular service unless the Store's associated Account type implements the required methods.

Monolith or Microservices

The service pattern enables all of the components to be used separately as microservices and bundled together into an all-in-one node. To make a standalone microservice, a given service, such as the StreamReceiverService, can be attached to an HttpServerService so that it will respond to individual Prepare packets forwarded to it via HTTP. Alternatively, the same StreamReceiverService can be chained together with other services so that it will handle Prepare packets meant for it and pass along any packets that are meant to be forwarded.

Some bundles of specific functions are available through the CLI. If there are other bundles that you would find useful, please feel free to submit a Pull Request to add them!

Stores - Database Abstraction

The Store is the abstraction used for different databases, which can range from in-memory, to Redis, to SQL, NoSQL, or others.

Each service that needs access to the database can define traits that the Store type it is passed must implement. For example, the HttpServerService requires the Store to implement a get_account_from_http_auth method that it will use to look up the Account details for an IncomingRequest based on the authentication details used for the incoming HTTP request. The Router requires the Store to provide routing_table and get_accounts methods that it uses to determine the next Account to send the request to and load the Account details from the database.

Store types can implement any or all of the service-specific Store traits. The Rust compiler ensures that a Store passed to a given service implements the trait(s) and methods that that service requires.

This approach to abstracting over different databases allows to be used on top of multiple types of databases while leveraging the specific capabilities of each one. Instead of having one database abstraction that is the least common denominator of what each service requires, such as a simple key-value store, each service defines exactly what it needs and it is up to the Store implementation to provide it. For example, a Store implementation on top of a database that provides atomic transactions can use those for balance updates without the balance service needing to implement its own locking mechanism on top.

A further benefit of this approach is that each Store implementation can decide how to save and format the data in the underlying database and those choices are completely abstracted away from the services. The Store can organize all account-related details into sensible tables and indexes and multiple services can access the same underlying data using the service-specific methods from the traits the Store implements.

You can’t perform that action at this time.