Integration and Interoperability Facilities Framework: Client Libraries Design Model

From Gcube Wiki
Revision as of 11:18, 13 April 2012 by Rena.tsantouli (Talk | contribs) (Created page with '=Objective= The scope of the activities can be confined by determining how Client Libraries and Clients are perceived within this framework layer and by defining the goals target…')

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to: navigation, search

Objective

The scope of the activities can be confined by determining how Client Libraries and Clients are perceived within this framework layer and by defining the goals targeted within its evolution.

Client Libraries

Work withing CL framework focuses on a subset of the client libraries found within the system, those that mediate access to some of the system services. The objective of the task does not involve the evolution of services, nor of client libraries that offer functions other than access to services. For convenience, the reference to client libraries within the sphere of influence of the task is made as CLs.

Clients

The framework targets clients written in Java. It is expected that most such clients will be other components within the system but the framework will address also external clients that may find it convenient to use the CLs over generic REST/WS client libraries. In either case, zero assumptions are made on the clients, allowing them to range from pure clients (standalone applications within a dedicated JVM) to other managed services that run within some container.

Goals

The task aims at promoting consistency across CLs in all aspects that transcend the semantics of individual target services. For each cross-cutting concern the steps towards the framework integration are as follows:

  • Identify best practices
  • Codify practices in guidelines
  • Document guidelines
  • Monitor the adoption of guidelines across the CLs

Models

The framework distinguishes concerns that relate to CL design from those that relate to CL management and evolves two separate models for their structuring: the Design Model and the Management Model.

  • Design Model: The Design Model for CL addresses cross-cutting design concerns within the system libraries, that include at least the following issues: scoped calls (how scope information is to be added to client calls), secure calls (how security information is to be added to client calls), endpoint management (how services ought to be referred to, discovered, and selected), addressing, discovery, replica management, caching, asynchronous operations (how asynchronous operations ought to be implemented), callbacks, futures, notifications, streamed/bulk operations (how streamed/bulk operations ought to be implemented), fault handling: how should faults be handled.
    • Throughout these concerns, driving design principles are: simplicity, testability, evolvability and, where appropriate, standards compliance.
    • Consistency is more readily and conveniently achieved through shared implementations of common solutions. In this sense, the work within the framework evolution will also be concerned with the delivery of new system components that support the development of CLs. It is expected that these Support Libraries will form a framework for CL development.
  • Management Model: The model for CL management will address at least the following (inter-related) issues:
    • module structure: relationship between CL modules, stub modules, and service modules
    • build outputs: what secondary artifacts are associated with CLs
    • release cycle: how are CLs released with respect to target services
    • change management: how changes in target service API should be handled
    • profiling and deployment: how should CLs be profiled for dynamic deployment
    • distribution: how should CLs be packaged for distribution

Design Model API For Clients

Assumptions and Terminology

Let foo be a service within the system. In what follows, we discuss the design of a client-side API for foo. In the process, we outline a generic model for similar APIs based on a small number of classes and interfaces. These compilation units are placed in org.gcube.common.clients.api package and are implemented in a common-clients-api library.

We work under the following assumptions and using the following terminology:

  • services: foo is an HTTP service, in that it uses HTTP at least as its transport protocol. At the time of writing, all system services are more specifically WS RPC services, i.e. use SOAP over HTTP to invoke service-specific APIs. Some such services are stateless, in that their endpoints do not manage any form of state on behalf of clients. Other services are instead stateful, in that their endpoints host a number of service instances, all of which maintain state for a class of clients [1]. In the future, system services may also be REST services, in the broad sense of stateless services that use HTTP as their application protocol[2].
  • deployments: foo may be (statically or dynamically) deployed at multiple network addresses. We refer to a service deployment at any given address as a service replica[3]. Discovery Services are available within the system to locate service endpoints that are deployed at different addresses.
  • scoped requests: foo may operate in multiple scopes, where each scope partitions the local resources that are visible to its clients, as well as the remote resources that are visible to the operations that foo carries out on behalf of its clients. In particular, the operations of foo may result in the creation of state in a given scope, either locally to foo endpoints or remotely, by interaction with other services that create state on behalf of foo and/or its clients. Service scoping requires that requests to foo are scoped, marked with the scope within which they are intended to occur. Unscoped requests or requests made outside one of foo’s scopes are rejected by it.
  • secure requests: foo may perform a range of authentication and authorisation checks, including scope checks, in order to restrict access to its operations to distinguished clients. Service security requires that requests made to foo be marked with adequate credentials. Unsecure requests or secure requests that fail authorisation checks are rejected by foo.
  • clients: a client of foo may be internal to the system (i.e. a system component in turn) or external to it. Clients often operate within a dedicated runtime, and in this case we refer to them as pure clients. In other cases, they share a common runtime and, like foo, they may be managed by some container. In particular, clients may be services in turn, and in this case we refer to them as a client services.
  1. terminology: the system has traditionally used a different terminology for its services. Service instances are called WS-Resources, as WSRF is the set of standards with which they are uniformly exposed at the time of writing. We prefer here the term “service instance” for its wider usage. Note also that WS-Resources and use WS-Lifetime, WS-ResourceProperties and WS-Notification protocols to expose, respectively, lifetime operations, the values of distinguished properties of their state, and subscriptions for/notifications of changes to the values of those properties. Some services capitalise on these standards and become stateful even when they expose a single instance. These stateful services are known as singleton services.
  2. teminology: services have been often described within the system as a collection of one or more “port-types”, following the terminology endorsed by WSDL 1.x standards, and then abandoned in WSDL 2.x standards. For its wider adoption and technological independence, we prefer here to follow common terminology whereby a port-type is a service in its own right.
  3. terminology: the term “running instance” has been used within the system to indicate a service deployment at a given network address. We prefer here the term “service replica” to avoid confusion with “service instance”, which is more commonly associated with stateful services.

Goals and Principles

Within the previous assumptions, our model is motivated by a goal of consistency across different client APIs. In particular, the model will:

  • decrease the overall learning curve associated with using the system;
  • increase API quality via sharing of best design practices;
  • decrease API first-time and maintenance development costs via shared libraries;
  • decrease API documentation costs by reference to shared design elements;

To achieve our goals, we base the model on a set of design principles. In no particular order, these include:

  • generality: the model will endorse design solutions that do not limit its applicability to the range of services and clients outlined above;
  • coverage: the model will address a wide range of issues that transcend the semantics of individual services, including scoping issues, security issues, replica discovery and management issues, and fault management issues;
  • transparency: the model will endorse design solutions that simplify client usage, particularlywith respect to requirements that are specific to our system;
  • testability: the model will not endorse design solutions that reduce or unduly complicate the possibility of unit testing for clients;

Service Proxies

The design approach we consider is service-centric [1].

The service is represented in client code with a single abstraction and clients invoke its methods to interact with remote service endpoints or service instances [2]. 

In common jargon, this abstraction is understood as a service proxy [3]. The service proxy for foo is an interface:

interface Foo {...}

The interface lists methods used to interact with service endpoints, and the methods are implemented by a default implementation to be used in production:

class DefaultFoo implements Foo {...}

The interface encourages clients to separate the use of Foo instances from their instantiation. A client component may use an injected implementation of Foo, created elsewhere in client code. During testing, the component may be injected with a fake implementation of Foo which produces outputs and failures as required to drive the tests, e.g. a mock implementation or a stubbed implementation.

Alternatively, the component may lookup Foo implementations from a factory, and in this case it is the factory that may be configured during test setup so as to return a fake implementation. There are many well-known ways to design client components based on dependency injection and lookup (constructor injection, setter injection, manual injection, container-managed injection, concrete factories, abstract factories, ...). In all cases the availability of an interface enables clients to test their code independently of the network.

  1. An alternative approach is operation-centric, in that the service is represented indirectly by local models of the operations that comprise its API. We choose a service-centric approach for the familiarity of its programming model, and because it is simpler to implement and use against large service APIs.
  2. In the following, we avoid unnecessary distinctions between service endpoints and service instances, and use the term service endpoint to refer to both.
  3. terminology: technically, we are dealing with a service façade rather than a proxy. This is because its API may differ substantially from the API of the service, as we discuss in detail later. We choose the term proxy because it is more widely understood.

Proxy Lifetime

DefaultFoo may be instantiated in either one of two modes:

  • in direct mode, instances are bound to service endpoints explicitly addressed by clients. This mode serves clients that obtain addressing information from interactions with other APIs. It

may also be used to point tools towards statically known endpoints, or else during integration testing, typically to interact with endpoints deployed on local hosts.

  • in discovery mode, instances are configured with a query for service endpoints provided by clients. They are then responsible for submitting the query to the Directory Services of the system, and for negotiating bindings to service endpoints based on the corresponding results. This mode serves clients that have information which characterise the target endpoints and from which addressing information can derived.

The binding mode of the instance is carried by a FooConfig instance, along with other client directives that control how DefaultFoo instances mediate access to the bound endpoint(s). We discuss below how clients instantiate FooConfig to indicate the binding mode of DefaultFoo instances. We also review other configuration options as they become relevant to the discussion.

Clients pass the FooConfig instance to the only constructor of DefaultFoo:

DefaultFoo(FooConfig config) {...}

The following holds true:

  • instantiation is a local operation. Calls to the bound endpoints will be issued only when clients invoke the Foo methods implemented by a DefaultFoo instance;
  • clients may use the same instance to issue one or more calls to the bound endpoint(s) in a given scope. They may create multiple instances to call foo endpoints at different times and in different scopes. Equally, they may use a single instance for all their calls in any scope, i.e. use DefaultFoo as a singleton class (but see below for instances created in direct mode). The API makes no assumption on the lifetime of instances;
  • the lifetime of an instance terminates when it becomes eligible for garbage collection. In this respect, the instance behaves like a standard Java object and does not require any explicit termination signal from clients.
  • since clients may create an arbitrary number of instances, individual instances retain only their configuration and treat it as immutable state. The API gives this guarantee by making the configuration immutable or by cloning the configuration when DefaultFoo is instantiated with it. DefaultFoo instances offer no methods to change the configuration from which they have been created.
  • since instances are immutable, clients may safely use a DefaultFoo instance from multiple threads.

Finally, note that:

  • an instance created in direct mode may only be used to issue calls in one the scopes of its bound endpoint. Client that operate in multiple scopes should bear in mind the risks of sharing these instances for calls made in different scopes;
  • an instance created in discovery mode may be used to issue calls in any scope, as it will be dynamically bound to endpoints in that scope;

Direct Mode

FooConfig has one or more public constructors that take the address of a service endpoint or, depending on the design of the service, a reference to a service instance available at a given endpoint [1]. The resulting FooConfig instance can be used to create instances of DefaultFoo which are bound for their entire lifetime to the addressed endpoint, i.e. cannot be used as proxies for other service endpoints.

Endpoint Addresses

If foo is a REST service, a stateless WS service, or singleton WS service, the address of its endpoints can be univocally derived by the name and port of their network hosts. FooConfig complements this information with service-specific constants and obtains the complete address of the endpoint (e.g context paths). FooConfig validates the complete address and raise issues of well formed-ness with an IllegalArgumentException.

FooConfig(String host, int port) throws IllegalArgumentException {...}

FooConfig may also be instantiated with a java.net.URL which subsumes the required addressing information:

FooConfig(URL address) throws IllegalArgumentException {...}

This constructor is used when clients obtain endpoint addresses from other APIs. FooConfig remains responsible for validating or complementing the address for the target service. It may also be responsible for translating the address in the model expected by lower-level communication APIs which DefaultFoo may use in turn.

  1. terminology: where relevant, we differentiate between the network address of a service endpoint and a reference to a service instance available at a given endpoint. A reference subsumes an address and complements it with parameters that identify one instance at that address. When this distinction is unnecessary, we speak uniformly of the address of a service endpoint or service instance.

Endpoint References

If foo is a stateful WS service, FooConfig has a constructor that accepts host coordinates as well as an instance identifier. The API will solicit the identifier under the semantics which is most appropriate to service instances (e.g. sourceId if instances encapsulate state about some data source):

FooConfig(String host, int port, String id) throws IllegalArgumentException {...}

FooConfig has also a second constructor that accepts a javax.xml.ws.wsaddressing.W3CEndpointReference whose reference parameters identify a service instance at a given address.

FooConfig(W3CEndpointReference reference) throws IllegalArgument Exception {...}

As above, FooConfig is responsible for validating the reference and for translating it into the addressing model of any lower-level communication API that DefaultFoo may use in turn (e.g. EndpointReferenceType in Axis’ generated stubs API).

Discovery Mode

FooConfig may also be instantiated with a query for service endpoints. The resulting instance may be used to create DefaultFoo instances that attempt to bind to different endpoints at different points in their lifetimes.

FooConfig(FooQuery query) {...}

Queries

FooQuery allows clients to specify one or more properties of the service endpoints they wish to access. The query contains no explicit reference to the concrete query syntax which the discovery APIs used by DefaultFoo may require, nor any reference to the query submission mechanisms that that API may provide. DefaultFoo is responsible for synthesising a concrete query from the properties specified by the client. At its simplest, FooQuery may be a bean class. If no properties are mandatory in queries, it will have at least a no-parameter constructor for queries for arbitrary endpoints of the target service (in no case will clients be exposed to service constants, e.g. service class and name):

FooQuery query = new FooQuery();

or

FooQuery query = new FooQuery(...);
query.setXXX(....);
...

If there are many possibilities for query customisation, different query classes may be provided by the API, each of which has a contained range of configuration options. Alternatively, FooQuery may expose only a package-protected constructor and require that its instances be created with a builder class placed in the same package, or with more sophisticated forms of fluent APIs (e.g. full-fledged DSLs). As a simple example:

 class FooQueryBuilder {
    .....
    public static FooQueryBuilder query() {
        return new FooQueryBuilder();
    }
    public forXXX(....) {...}
    public withYYY(....) {...}
    ...
    public FooQuery build() {....access package-protected constructor...}
}
...
FooQuery query = query().forXXX(...).withYYY().....build();


The following holds true about queries:

  • if clients create multiple FooConfig instances, they may create multiple FooQuery instances or share a common instance, i.e. use FooQuery as a singleton class. Like for DefaultFoo instances, the API makes no assumption on the lifetime of individual queries;
  • since clients may create multiple FooQuery instances, individual instances retain only immutable state. The endpoint properties specified in the instance cannot be altered after its creation.
  • since FooQuery instances are immutable, clients may safely use an instance from multiple threads.

Endpoint Management

DefaultFoo attempts to bind to the service endpoints that answer FooQuery. It does so combining a binding strategy and a caching strategy.

According to its binding strategy, a DefaultFoo instance will:

  • submit the query with the Directory Services of the system in the current scope;
  • process the discovered endpoints as follows:
    • attempt to bind the next available endpoint whenever an endpoints returns a failure with retry-equivalent semantics;
    • queue endpoints that return a failure with retry-same semantics and re-attempt to bind them after failing to bind all the remaining endpoints. Repeat the process on a single endpoint for a fixed number of times;
    • abort further binding attempts as soon as one endpoint returns a failure with unrecoverable semantics;
    • return the failures encountered during the last binding attempt if all binding attempts fail;
  • logs all the previous actions at INFO level;

According to its caching strategy, the DefaultFoo instance will:

  • cache the address of a successfully bound endpoint;
  • bind to the endpoint at a cached address before submitting a query, if any exists;
  • remove from the cache the address of an endpoint when the instance cannot bind to it;
  • logs of all previous actions at DEBUG level;

Since clients may use multiple FooQuery instances:

  • the cache is not part of the state of individual instances, which remain immutable. Rather all DefaultFoo instances share the same cache. An instance created after an address has been cached will still attempt to bind first to the endpoint at that address.
  • the cache is indexed by the query and current scope, so that a cache returns a hit only when DefaultFoo instances are configured with the same query and used in the same scope under which the address was originally cached. Depending on the API, this may require a nondefault notion of equivalence between queries and that query classes implement hashcode() and equals() according to such notion.

The binding and caching strategies remain largely opaque to clients. Clients limits their involvement to:

  • providing queries for service endpoints;
  • when possible, observing and reacting to discovery faults, such the lack of suitable endpoints or the occurrence of faults in the interaction with the Directory Services;

We discuss failure handling in detail later on in the document.

Proxy API

After creating DefaultFoo instances, clients invoke the methods of Foo to issue calls to the service endpoints bound to the instances. Calls may take zero or more inputs, produce zero or one output, and raise one or more faults. Foo models inputs, outputs, and faults with the types that seem most convenient for its clients. The local types may differ substantially from those defined in the remote API of the service. DefaultFoo instances are responsible for converting between local types and remote types. Even when the remote types seem adequate for Foo clients, adapting them to equivalent local forms helps Foo to insulate its clients from future changes to the remote API.

Local types are virtually unconstrained from a design perspective. For example, they may:

  • be constructed in a variety of patterns, including standard constructors, copy constructors, factories, builders, and more sophisticated forms of fluent APIs. When useful, they may deserialised from various representations, from language serialisation formats to, say, XML formats;
  • exhibit arbitrary behaviour, including validation behaviour at creation time or at any other point in their lifetime;
  • implement arbitrary interfaces and participate in arbitrary hierarchies;
  • use type parameters for type-safe reuse;
  • be arbitrarily annotated;
  • have non-trivial notions of equivalence, cloning behaviour, and useful String serialisations;

Similar freedom extends to the design of Foo. Foo may implement any interface, participate in any hierarchy, be arbitrarily annotated and parameterised. Furthermore, Foo may use method name overloading for calls that have related semantics but require a different number of inputs, or inputs of different types.

The API uses this freedom towards the goals of:

  • clarity and fluency, by choosing types that simplify client programming;
  • correctness, by choosing types that detect locally, and often even statically, constraint violations which would be only enforced remotely and dynamically by foo;
  • standardisation, by choosing types that are formal or de-facto standards for the semantics of the data, either in the context of the language (common Java interfaces, appropriate Exceptions, naming conventions, etc.) or in a broader context.

We discuss below how the methods of Foo are designed to model calls to foo endpoints. In particular, we look at choices of local types for inputs, outputs, and faults for prototypical calls, including calls that require or produce data collections, asynchronous calls, and calls that access the state of stateful service instances.

Example

The possibilities for the design of Foo are open ended. We illustrate some of options here using a fictional example. The example is intentionally convoluted to illustrate a wider range of options.

Assume foo exposes a operation bar which:

  • expects a rather complex and potentially recursive XML data structure Baz in input;
  • returns a simpler complex data structure Qux whenever Baz satisfies a set of constraints, from simple constraint (some attributes must not be null, other must be null) to complex constraints (some simple elements must have correlated values)
  • raises an InvalidBazFault when the input structure is null, is syntactically or structurally malformed, or does not satisfy the expected set of constraints;

Foo mediates calls to bar with the following method:

Qux bar(Baz baz) throws IllegalArgumentException, ServiceException;

where:

  • Baz is a class that uses the annotations of JSR 222 (JAXB 2.0) to bind its instances to XML, and the annotations of JSR 303 to declare validity constraints upon them which cannot be detected by the type-checker. The API offers a BazBuilder to fluently construct Baz instances across its plethora of mandatory and optional parameters, and Baz instances expose a set of sophisticated methods that allow clients to flexibly navigate its potentially very deep and recursive structure, including a query method based on XPath expressions. Baz instances override equals(), hashcode() and toString() to facilitate assertions in tests as well as debugging;
  • DefaultFoo instances throw:
    • an IllegalArgumentException if the input is null or invalid, enforcing JSR 303 annotations for the purpose. The instances short circuit a remote call that would certainly fail and throws a local exception instead. They make sure that a null attribute violation is detected before the call (direct mode) or the query (discovery mode) are issued;
    • a generic ServiceException in correspondence with any other form of remote failure. We discuss below the semantics of this exception and more generically the rationale for Foo’s approach to failure reporting.
  • Qux is a fairly simple bean class, also decorated with JAXB annotations so that where the XML representation included a collection of uniquely named values, Qux exposes instead a Map of String keys. Furthermore, the Qux instances returned by bar() have been proxied, so that the invocations of some of its key methods can be intercepted, to some particular end. It also exposes methods that accept subscriptions and produce notifications in response to some key events of its lifetime.

Faults

In its role of mediation between clients and service, the API may need to report a wide range of failures, including:

  • failures that occur in the client runtime, before remote calls to foo are issued;
  • failures that occur in the attempt to communicate with foo;
  • failures that occur in the runtime of foo, and that foo dutifully reports to its clients;

We distinguish between the following types of failures:

  • errors: these are violations of the contract defined by the API which can imputed to faulty code or faulty configuration, and which have escaped testing. Malformed inputs are prototypical examples of client-side errors, while service implementation bugs are prototypical examples of service-side errors;
  • contingencies: these are failures that are predicted in the contract defined by the API as violations of pre-conditions. There may be no bugs in either client or service code, but the service is in a state that prevents it to carry out the client’s request. Data that cannot be found or cannot be created are prototypical examples of contingencies;
  • outages: these are I/O failures of the external environment, from network failures, to database failures and disk failures. A client that cannot access the network, a service endpoint that is not reachable, an invocation that times-out, a corrupted database at the service side are all examples of outages.

The design of the API cannot, and indeed should not, predict that strategies that clients will adopt to handle this range of failures. However, it may assume that:

  • in production, clients will at least contain all forms of failure, i.e. fully log them and conveniently report them to users or clients further upstream. Silencing failures or thread terminations are typically undesirable outcomes. Failure containment is normally dealt within error handlers that act as ‘barriers’ or ‘points-of-last-defence’ high-up in the call stack.
  • clients may have coping strategies for contingencies that go beyond simple failure containment. The may be able to actually recover from the failures, e.g. by retrying with different inputs or by selecting an alternative execution path, including calling another service or falling back to defaults. Typically, clients will recover as close as possible to the observation of the failure, though not necessarily in the immediate caller.
  • clients are more likely to recover from contingencies than from outages. This is because contingencies are specific expectations set forth by the API that clients should be prepared to handle somehow.

Based on these assumptions, Foo aligns with modern practices in:

  • using unchecked exceptions to report errors and outages. Clients that may only contain such failures in generic error handlers will be dispensed from the noisy, error-prone, brittle, and ultimately pointless task of explicitly catching and/or re-throwing exceptions along the call stack.
  • using checked exceptions to report contingencies. Clients may then avail themselves of the services of the typechecker to be alerted of failures that they should have prepared for.

In any case, Foo documents all the exceptions that its methods may throw, regardless of their type.

More specifically, Foo’s methods report:

  • all the errors that may be detected in the client runtime prior to calling a service endpoint. In its bar() method above, for example, Foo declares an IllegalArgumentException in lieu of the InvalidBazFault that service would raise if DefaultFoo actually called its bar operation;
  • all the contingencies the foo declares to raise. If the service declares an UnknownBazFault for its bar operation, for example, then Foo declares a corresponding checked exception for its method bar(), and DefaultFoo throws the exception upon receiving the fault from a service endpoint. If foo declares a base class for a number of related contingencies, and if its operation bar may throw all the subclasses of the base class, then Foo declares only the base class for its method bar();
  • a single ServiceException for any outage, or for any error that cannot be detected in the client runtime prior to calling a service endpoint.

ServiceException marks the non-local semantics of Foo’s methods and serves as a base class or else as a wrapper for any other exception that DefaultFoo may observe. In particular, ServiceException is defined as follows:

package org.gcube.common.clients.api;
class ServiceException extends RuntimeException {
 private static final long serialVersionUID = 1L;
 public ServiceException(Exception cause) {
 ! super(cause);
 }
}

DefaultFoo wraps in ServiceExceptions any exception that its lower-level communication API may throw at it. For example, if foo is a JAX-WS Web Service, DefaultFoo wraps in a ServiceException any WebServiceException thrown by its JAX-WS-compliant API of choice. If foo is JAX-RPC Web Service, DefaultFoo wraps in in a ServiceException any RemoteException or SOAPFaultException thrown by its JAX-RPC-compliant API of choice. In all cases, DefaultFoo documents what exceptions may cause the ServiceExceptions that its instances may throw.

DefaultFoo also throws ServiceExceptions when its instances are created in discovery mode and observe failures in the process of discovering foo endpoints. For this, DefaultFoo uses a DiscoveryException, or an instance of its subclass NoSuchEndpointException, as appropriate. DiscoveryException and NoSuchEndpointException are defined as follows:

package org.gcube.common.clients.api;
class DiscoveryException extends ServiceException {
 private static final long serialVersionUID = 1L;
 public DiscoveryException(Exception cause) {
  super(cause);
 }

}
package org.gcube.common.clients.api;
class NoSuchEndpointException extends DiscoveryException {
 private static final long serialVersionUID = 1L;
 public NoSuchEndpointException(Exception cause) {
  super(cause);
 }
}

Clients that may only contain errors and outages may conveniently catch ServiceExceptions in their error handlers. Clients that wish to customise their containment strategies for particular outages, or that can even recover from them, may inspect the cause of ServiceExceptions and/or directly catch DiscoveryExceptions.

Bulk Inputs and Outputs

DefaultFoo may need to delegate to foo‘s operations that that take or return collections of values. Foo may then rely in its API on custom interfaces or classes that encapsulate the collection values required or provided by foo, e.g.:

Nodes nodes(Paths paths) throws ... ;

where Nodes and Paths are ad-hoc models of nodes and path to nodes of some tree-like data structure.

More commonly, however, Foo defines methods that rely on the standard Java Collections API. When methods return collections of values, Foo choose Lists:

List<Node> nodes(...) throws ... ;

In returning Lists, Foo is not necessarily conveying to clients that the order of Nodes is meaningful, or that the same Node may occur twice within the List. Rather, Foo is following two principles: a) the type that best models a collection of values may only be defined by its consumers, on the basis of their own processing requirements; b) some types are more versatile than others in adapting to a wider range of processing requirements. In its ignorance of how clients will consume the collection, Foo returns it as a List for the versatility of the List API, and in the assumption that when its clients are better served by other, more constrained Collection types they can easily and cheaply derive them from Lists.

For methods that take collections however, Foo acts as a consumer and chooses the Collection type that most closely captures the required constraints at compile-time, e.g. a Set if Foo expects no duplicates:

List<Node> nodes(Set<Path> paths) throws ... ; 


On the other hand, Foo does not restrict the semantics of inputs more than it should. For example, if there are no particular requirements on input collections, Iterator or Iterable are the most flexible choices, as they make the API immediately usable with a broader set of abstractions than Collections:

List<Node> nodes(Iterable<Path> paths) throws ... ;

The choice between Iterable and Iterator is not clearcut. Iterable can improve the fluency of both client and implementation code, but requires materialised collections. This may be desirable in itself as an indication that the collections will be materialised in memory and that very large streams coming from secondary storage or network are not expected. When streams are not large, however, Iterable forces clients to accumulate their elements before they can use the API.

Asychronous Methods

Calls to foo may be synchronous or asynchronous:

  • synchronous calls block clients until they have been fully processed by foo endpoints and their output, or just an acknowledgement of completion, is returned to clients. This temporal coupling between clients and endpoints forces both to relinquish some control over their computational resources. Clients must suspend execution in the calling thread and endpoints cannot schedule their availability to answer. It also requires calls to be fully processed within communication timeouts. Synchronous calls are thus preferred when endpoints can process them quickly, i.e. when the time in which clients and endpoints synchronise is short. This is the case when calls generate short-lived process and require the exchange of limited amounts of data;
  • asynchronous calls do not block clients, either because they return no output (i.e. the operations are one-way) or because their output can be produced and returned to clients at a later time. This leaves clients and endpoints in control of their computational resources, but it complicates the programming model at both sides. Asynchronous calls are preferred when endpoints can fully answer only after long-lived processes, including those required to exchange large datasets;

foo may pursue the benefits of asynchrony by designing and implementing its operations for it. One way operations return immediately with an acknowledgement of reception. Operations that produce output may return the endpoint of another service that clients can poll to obtain the output, when this becomes available (polling). Alternatively, foo may require that clients indicate an endpoint that foo endpoints can call back to deliver the output (callbacks). In all cases, foo execute the operations in background threads.

Foo may pursue the benefits of asynchrony even if foo does not. In other words, Foo may offer asynchronous calls over synchronous remote operations. In practice, this amounts to calling endpoints in background threads. Polling and callbacks remain available as patterns for the delivery of output between threads, though their implementation is now local to clients. The approach does not cater for communication timeouts, hence for calls that generate long-lived processes at foo endpoints. However, it allows clients to make further progress while the endpoints are busy processing their calls.

Polling And Callbacks

An asynchronous call that induces a long-lived process at the service endpoint may return immediately with a reference to the ongoing process. Clients may then use the reference to wait for the process to complete only when they need its outcome to make further progress. They may also poll the status of process and perform other work while it is still ongoing.

In Java, the standard model for such references is provided by Futures. For example, Foo defines the following method:

Future<String> barAsync(...) throws ... ;

which promises to return a String when this becomes available. Clients use Future.get() methods to block for the output, indefinitely or for a given amount of time. They can use Future.isDone() to poll the availability of any output. They can also use Future.cancel() to revoke the submission of a call (in case this has been scheduled but not issued yet) or, if the service allows it, to cancel the remote process.

We assume that barASync() declares failures following the strategy discussed previously, with the understanding that these are failures that may occur only before foo starts processing calls (including failures thrown by DefaultFoo before calls are actually issued). Failures raised by foo in the context of processing calls will instead be delivered in Future.get() methods, in accordance with the Future API. In particular, unchecked ServiceExceptions and checked contingencies will be found as the cause of ExecutionExceptions thrown by Future.get() methods.

If the underlying remote operation is one-way, Foo defines barAsync() as follows:

Future<?> barAsync(...) throws ...;

which returns a wildcard Future that clients may use to cancel submissions/processes, as above, or that they ignore altogether in case fooAsync is conceptually fire-and-forget.

In addition to polling, Foo may also rely on callbacks to deliver call outputs to its clients. In this case, Foo requires clients to provide a Callback instance at call time, i.e. an instance of the following interface:

package org.gcube.common.clients.api;
interface Callback<T> {
 public void onFailure(Throwable failure); 
 public void done(T result);
}

Specifically, Foo may overload barAsync as follows:

Future<?> barAsync(..., Callback<String> callback) throws ... ;

The method promises to return immediately with a wildcard Future, which clients can use as above, and to deliver the outcome to the Callback instance as soon as this becomes available. The delivery occurs through two different callbacks, depending on whether the outcome is a success (done()) or a failure (onFailure()).

Clients may entirely consume the output in the Callback instance. Alternatively, they are responsible for exposing it directly or indirectly to other components.

Streams

With polling and callbacks, Foo let its clients perform useful work as they wait for the output of long-lived processes that execute at foo endpoints. The approach however does not directly address the case in which the output itself is a large dataset. In this case, clients must still block waiting for the whole dataset to be transferred before they can start processing it. They also need to allocate enough local resources to contain the dataset in its entirety. Similar demands are faced by foo, which needs to produce and hold the entire dataset before it can pass it to its clients. Thus large datasets may reduce the responsiveness of clients and the capacity of service endpoints.

foo and its clients may avoid these issues if they produce and consume data as streams. A stream is a lazily-evaluated sequence of data elements. Clients consume the elements as these become available, and discard them as soon as they are no longer required. Similarly, endpoints produce the elements as clients consume them, i.e. on demand.

Streaming is used heavily throughout the system as the preferred method of asynchronous data transfers between clients and services. The gRS2 library provides the required API and the underlying implementation mechanisms, including paged transfers and memory buffers which avoid the cumulative latencies of many fine-grained interactions. The API allows services to “publish” streams, make them available at a network endpoint through a given protocol. Clients obtain references to such endpoints, i.e. stream locators, and clients resolve locators to iterate over the elements of the streams. Services produce elements as clients require them, i.e. on demand.

Data streaming is used in a number of use cases, including:

  • foo streams the elements of a persistent dataset;
  • foo streams the results of a query over a persistent dataset;
  • foo derives a stream from a stream provided by the client;

The last is a case of circular streaming. The client consumes a stream which is produced by the service by iterating over another stream, which is produced by the client. Examples of circular streaming include:

  • bulk lookups, e.g. foo streams the elements of a dataset which have the identifiers streamed by the client;
  • bulk updates, e.g. foo adds a stream of elements to a dataset and streams the outcomes back to the client;

More complex uses cases involve multiple streams, producers, and consumers.

The advantages of data streaming are offset by an increased complexity in the programming model. Consuming a stream can be relatively simple, but:

  • the assumption of remote data puts more emphasis on correct failure handling at foo and its clients;
  • since streams may be partially consumed, resources allocated by foo for streaming need to be explicitly released;
  • consumers that act also as producers need to remain within the stream paradigm, i.e. avoid the accumulation of data in main memory as they transform elements of input streams into elements of outputs streams;
  • implementing streams is typically more challenging that consuming streams. Filtering out some elements or absorbing some failures requires look-ahead implementations. Look-ahead implementations are notoriously error prone;
  • stream implementations are typically hard to reuse (particularly look-ahead implementations);

Thus streaming raises significant opportunities as well as non-trivial programming challenges. The gRS2 API provides sophisticated primitives for data transfer, but it remains fairly low-level when it comes to producing and consuming streams.

The streams library provides the abstractions required to simplify further stream-based programming in simple and complex scenarios. It implements a DSL for stream manipulation which is built around the Stream interface, an extension of the familiar Iterator interface. The DSL simplifies a range of stream transformations, making it easy to change, filter, group, and expand the elements of input streams into elements of output streams. The DSL also allows to configure failure handling policies and event notifications for stream consumption, and it simplifies the publication of streams as gCube ResultSets.

Foo relies on the DSL of the Streams API whenever its methods need to take and/or return streams. For example, if foo can stream the results of a given query, for example, Foo<<code> may provide its clients with the following method:

Stream<Item> query(Query query) throws ... ;

where <code>Item and Query model, respectively, the elements of a remote dataset and a query issued against that dataset, and where the output Stream gives access to a remote gCube Resultset produced by foo. Clients are free to access the locator of the stream with Stream.locator() and consume it with the lower-level gRS2 API, if required.

Similarly, if foo can stream the elements with given identifiers, Foo may define the following method:

Stream<Item> lookup(Stream<Key> ids) throws ... ;

where Key models Item identifiers. By taking a Stream as input, Foo promises to publish the stream on behalf of clients and to send the corresponding locator to foo.

Since clients may want to remain in charge of publication, Foo overloads lookup() as follows:

Stream<Item> lookup(URI idRs) throws ... ;

i.e. accepts directly the locator to a gCube Resultset of keys which has already been published by the client, or by some other party further upstream.

Both query() and lookup() model failures according to the strategy outlined above, with the understanding that these are failures that may occur only before foo starts producing streams (including failures thrown by DefaultFoo before calls are actually issued). Failures raised by foo in the context of producing streams instead be delivered during Stream<code> iteration, in accordance with the specification of the <code>Stream API. In particular, unchecked ServiceExceptions and checked contingencies will be found as the cause of StreamExceptions.

Finally note that Foo may return streams through polling and callbacks if foo can start producing them only at the end of long-lived processes, e.g.:

Future<Stream> pollStream(...) throws ... ;

or

Future<?> callbackStream(...,Callback<Stream> callback) throws ... ;

Service Instances

@TODO: Introduce shared semantics of service instances. Introduce ServiceInstance interface for such instances and InstanceFactory interface for services that create such instances.

Lifetime Methods

@TODO: discuss InstanceFactory’s create() method and ServiceInstance’s destroy() method.

Property Operations

@TODO: introduce ServiceInstance’s getProperties() and the InstanceProperties interface of its return value. @TODO: discuss Java bindings for InstanceProperties implementations. @TODO: discuss properties synchronisation model (when properties should be refreshed and how). @TODO: introduce PropertyListener interface and subscription model.

Context Management

In its role of proxy, DefaultFoo calls the remote operations of foo in a context which encompasses more information that the target service endpoint and the input parameters of the calls. In particular, calls occur always in a given scope and conditionally to the provision of credentials about the caller. An attempt to call foo in no particular scope, or in a scope in which the target endpoint does not exist, as well as calls that are issued anonymously will be rejected, either by DefaultFoo or by its target endpoints.

We discuss below, we describe how this contextual information is made available to DefaultFoo.

Scope Management

One way of providing DefaultFoo instances with scope information is to require their immediate callers to specify one when the instances are created. Making scope explicit, however, induces clients to propagate scope information across their call stack, and this may easily prove intrusive for their design.

A less intrusive approach is to bind scope information to the threads in which DefaultFoo instances issue remote calls. Clients remain responsible for making the binding, but they can do so further up the call stack, as early as scope information becomes available to them. Client components that execute on the stack thereafter need have no design dependencies on scope.

To implement this scheme, DefaultFoo relies on the common-scope library, which provides the tools required to bind and propagate scope as thread-local information. In particular, common-scope models scope as plain Strings and includes a ScopeProvider interface with methods to bind a scope with the current thread (ScopeProvider.set(String)), obtain the scope bound to the current thread (ScopeProvider.get()), and remove the scope bound to the current thread (ScopeProvider.remove()). ScopeProvider gives also access to a single instance of its default implementation, which can be shared between clients and DefaultFoo (the constant ScopeProvider.instance).

Thus a client component high up the call stack binds a scope to the current thread as follows:

String scope = ... 
ScopeProvider.instance.set(scope);

and, lower down the call stack, DefaultFoo obtains the same scope as follows:

String scope = ScopeProvider.instance.get();

Note that:

  • since the shared ScopeProvider is based on an InheritableThreadLocal, DefaultFoo may execute in any child thread of the bound thread;
  • if the current thread and its ancestors are unbound, the shared ScopeProvider attempts to resolve scope from the system property gcube.scope. When clients operate in a single scope, this property can be set when the JVM is launched and clients can avoid compile-time dependencies on ScopeProvider altogether;
  • clients that reuse threads to call foo in different scopes will need to explicitly unbind threads, and typically will do so in the same component that binds them;

Security Management

@TODO: introduce the security models. @TODO: introduce a SecurityProvider model? @TODO: disclaim on model mappings on transport protocol?

Session Management

Coding Guidelines

Naming Conventions

@TODO: introduce and motivate name conventions for interfaces, classes, packages.

Appendix A: Specifications

@TODO: briefly summarises model in terms of “may”, “should”, “must” specifications.

Appendix B: API

@TODO: list interfaces and classes defined by the model.

Appendix C: Framework Requirement and Guidelines

@TODO: identify scope for framework support.

Management Model For Clients