Integration and Interoperability Facilities Framework: Client Libraries Design Model
Contents
- 1 Objective
- 2 Design Model API For Clients
- 3 Management Model For Clients
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 thatfoo
carries out on behalf of its clients. In particular, the operations offoo
may result in the creation of state in a given scope, either locally tofoo
endpoints or remotely, by interaction with other services that create state on behalf offoo
and/or its clients. Service scoping requires that requests tofoo
are scoped, marked with the scope within which they are intended to occur. Unscoped requests or requests made outside one offoo
’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 tofoo
be marked with adequate credentials. Unsecure requests or secure requests that fail authorisation checks are rejected byfoo
.
- 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, likefoo
, 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.
- ↑ 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.
- ↑ 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.
- ↑ 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.
- ↑ 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.
- ↑ In the following, we avoid unnecessary distinctions between service endpoints and service instances, and use the term service endpoint to refer to both.
- ↑ 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 aDefaultFoo
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. useDefaultFoo
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.
- ↑ 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 multipleFooQuery
instances or share a common instance, i.e. useFooQuery
as a singleton class. Like forDefaultFoo
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 implementhashcode()
andequals()
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
wheneverBaz
satisfies a set of constraints, from simple constraint (some attributes must not benull
, other must benull
) to complex constraints (some simple elements must have correlated values) - raises an
InvalidBazFault
when the input structure isnull
, 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 ofJSR 222
(JAXB 2.0) to bind its instances to XML, and the annotations ofJSR 303
to declare validity constraints upon them which cannot be detected by the type-checker. The API offers aBazBuilder
to fluently constructBaz
instances across its plethora of mandatory and optional parameters, andBaz
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 overrideequals()
,hashcode()
andtoString()
to facilitate assertions in tests as well as debugging; -
DefaultFoo
instances throw:- an
IllegalArgumentException
if the input isnull
or invalid, enforcingJSR 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 anull
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 forFoo
’s approach to failure reporting.
- an
-
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 aMap
ofString
keys. Furthermore, theQux
instances returned bybar()
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 thatfoo
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 anIllegalArgumentException
in lieu of theInvalidBazFault
that service would raise ifDefaultFoo
actually called itsbar
operation;
- all the contingencies the
foo
declares to raise. If the service declares anUnknownBazFault
for itsbar
operation, for example, thenFoo
declares a corresponding checked exception for its methodbar()
, andDefaultFoo
throws the exception upon receiving the fault from a service endpoint. Iffoo
declares a base class for a number of related contingencies, and if its operationbar
may throw all the subclasses of the base class, thenFoo
declares only the base class for its methodbar()
;
- 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 ServiceException
s 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 ServiceException
s that its instances may throw.
DefaultFoo
also throws ServiceException
s 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 ServiceException
s 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 ServiceException
s and/or directly catch DiscoveryException
s.
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 List
s:
List<Node> nodes(...) throws ... ;
In returning List
s, Foo
is not necessarily conveying to clients that the order of Node
s 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 List
s.
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 Collection
s:
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 Future
s. 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 ServiceException
s and checked contingencies will be found as the cause of ExecutionException
s 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 ServiceException
s and checked contingencies will be found as the cause of StreamException
s.
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 String
s 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 anInheritableThreadLocal
,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 propertygcube.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 onScopeProvider
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.