Skip to content

Conversation

MakarovS
Copy link
Contributor

@MakarovS MakarovS commented Dec 7, 2019

Overview

Add transaction methods extraction class. It will be used by ServiceWrapper which would store a map of transaction methods and invoke them by an id.


See: https://jira.bf.local/browse/ECR-3923

Definition of Done

  • There are no TODOs left in the code
  • Change is covered by automated tests
  • The coding guidelines are followed
  • Public API has Javadoc
  • Method preconditions are checked and documented in the Javadoc of the method
  • Changelog is updated if needed (in case of notable or breaking changes)
  • The continuous integration build passes

MethodHandle methodHandle = lookup.unreflect(transactionMethod);
assertThat(transactions).hasSize(1);
MethodHandle actualMethodHandle = transactions.get(ValidService.TRANSACTION_ID);
assertThat(actualMethodHandle.toString()).isEqualTo(methodHandle.toString());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had assertThat(transactions).containsExactly(entry(ValidService.TRANSACTION_ID, methodHandle)); initially, but methodHandles are not equal to each other. Is there a better way to check this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If invocation of the transaction method were implemented as the task specifies,
it could be tested by invoking the operation and verifying that it was indeed invoked:

Object service = spy(new Service());
TransactionInvoker invoker = new TransactionInvoker(service);
invoker.invoke(id, context, bytes);

verify(service).operation(context, bytes);

@coveralls
Copy link

coveralls commented Dec 7, 2019

Coverage Status

Coverage increased (+0.1%) to 86.867% when pulling a5cbb3a on ECR-3923 into ce2174e on master.

Copy link
Contributor

@dmitry-timofeev dmitry-timofeev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. There are no tests for extraction of annotated methods (class hierarchies, annotation applied to interface methods — e.g., our QaService).
  2. There is no actual working invocation of the methods.
  3. Tests for invocation must be extended to ensure the implementation is correct:
  • Verify the invocation target
  • Cover more than a single transaction method.

import java.lang.annotation.Target;

/**
* Indicates that a method is a transaction method. The annotated method should:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation is inadequate — see the Transaction and Transaction#execute
(since this annotation applies to methods, it must be documented appropriately).

/**
* Returns the transaction type identifier which is unique within the service.
*/
int id();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As there are no other properties than 'id', it is sensible to use 'value' instead.

*
* @see TransactionMethod
*/
static Map<Integer, MethodHandle> extractTransactionMethods(Class<?> serviceClass) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of producing method handles of unknown signature? Is it
(byte[], TransactionContext) or (TransactionContext, byte[])? The caller won't be able
to determine that. It also won't work with arbitrary protobuf messages as arguments even if the order becomes fixed.

I think some object that can be invoked with context and byte[] and invoke the corresponding transaction method is needed.

static Map<Integer, MethodHandle> extractTransactionMethods(Class<?> serviceClass) {
Map<Integer, MethodHandle> transactions = new HashMap<>();
MethodHandles.Lookup lookup = MethodHandles.lookup();
while (serviceClass != Object.class) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method does several things making testing more complicated:

  • Finds all methods annotated with a particular annotation
  • Validates these annotations
  • Converts to something callable

At least the first step, contributing to this outer loop, must be extracted and tested
separately.

private static void validateTransactionMethod(Method transaction, Class<?> serviceClass,
Map<Integer, MethodHandle> transactions, int transactionId) {
checkArgument(!transactions.containsKey(transactionId),
"Service %s had more than one transaction with id: %s", serviceClass.getName(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service %s has more than one transaction with the same id (%s): first: %s; second: %s

transaction.getName(), serviceClass.getName());
checkArgument(transaction.getParameterCount() == 2, errorMessage);
for (Class<?> parameterType: transaction.getParameterTypes()) {
checkArgument(isParameterTypeValid(parameterType), errorMessage);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include the invalid parameterType. The present message might be enough now, but including the 'bad' parameter will become more important when we allow more than byte[]

+ " \"byte[]\" and \"com.exonum.binding.core.transaction.TransactionContext\"",
transaction.getName(), serviceClass.getName());
checkArgument(transaction.getParameterCount() == 2, errorMessage);
for (Class<?> parameterType: transaction.getParameterTypes()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code accepts (byte[], byte[]) but shouldn't. Please test that properly.

@Test
void validServiceMethodExtraction() throws Exception {
Map<Integer, MethodHandle> transactions =
TransactionMethodExtractor.extractTransactionMethods(ValidService.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think local classes will be quite appropriate.

MethodHandle methodHandle = lookup.unreflect(transactionMethod);
assertThat(transactions).hasSize(1);
MethodHandle actualMethodHandle = transactions.get(ValidService.TRANSACTION_ID);
assertThat(actualMethodHandle.toString()).isEqualTo(methodHandle.toString());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If invocation of the transaction method were implemented as the task specifies,
it could be tested by invoking the operation and verifying that it was indeed invoked:

Object service = spy(new Service());
TransactionInvoker invoker = new TransactionInvoker(service);
invoker.invoke(id, context, bytes);

verify(service).operation(context, bytes);

Exception e = assertThrows(IllegalArgumentException.class,
() -> TransactionMethodExtractor
.extractTransactionMethods(MissingTransactionMethodArgumentsService.class));
Method transactionMethod =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(here and elsewhere) I don't think it is appropriate to use reflection to only specify the expected method name.

* occurs. A correct transaction implementation must not throw such exceptions. The transaction
* will be committed as failed (status "panic").
*
* @see <a href="https://exonum.com/doc/version/0.12/architecture/transactions">Exonum Transactions</a>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

@dmitry-timofeev dmitry-timofeev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good! Also, we are almost with transactions-as-methods with no explicit TransactionConverter and de-serialization 🎉

}

/**
* Execute the transaction, possibly modifying the blockchain state.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that the copy-pasted user-facing documentation is not appropriate here.
This one may be more concise and bring a different message (what it does for which Service
implementations, not what it shall do as an interface implementation). The spec is different, and the target audience is completely different.

*/
void invokeTransaction(int transactionId, byte[] arguments, TransactionContext context)
throws TransactionExecutionException {
checkArgument(transactionMethods.containsKey(transactionId),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Meta) We must extend the task on errors to cover routing errors — this is an instance of such error.

checkArgument(transactionMethods.containsKey(transactionId),
"No method with transaction id (%s)", transactionId);
try {
MethodHandle methodHandle = transactionMethods.get(transactionId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine for this PR, but I'd remind that MHs won't work for any message (next task):

The caller won't be able
to determine that. It also won't work with arbitrary messages as arguments even if
the order is fixed.

if (throwable instanceof TransactionExecutionException) {
throw (TransactionExecutionException) throwable;
} else {
throw new RuntimeException(throwable);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Possibly for future improvement) The MH#invoke spec says

Throws:
Throwable – anything thrown by the underlying method propagates unchanged through the method handle call

So why don't we forbid checked exceptions in transaction methods (except TransactionExecutionException)
during discovery and throw RuntimeExceptions as is on invocation?

Please create an issue for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created ECR-3988

() -> invoker.invokeTransaction(ThrowingService.TRANSACTION_ID, ARGUMENTS, context));
assertThat(e.getErrorCode()).isEqualTo(ThrowingService.ERROR_CODE);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please test handling of 'other' service originated exceptions, since it is important to handle them properly.

May add a second method to 'ThrowingService'.

assertThat(e.getErrorCode()).isEqualTo(ThrowingService.ERROR_CODE);
}

class BasicService implements Service {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The classes below are unnecessarily nested classes of the test class — shall be made static.

* transaction, possibly modifying the blockchain state. The method should:
* <ul>
* <li>be public
* <li>have exactly two parameters of types 'byte[]' and '{@link TransactionContext}' in this
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter documentation (most importantly, what is byte[], also what is context for)?
The com.exonum.binding.core.service.TransactionConverter#toTransaction(int, byte[]) and Transaction#execute have it correspondingly.

*/
static Map<Integer, MethodHandle> extractTransactionMethods(Class<?> serviceClass) {
Map<Integer, Method> transactionMethods = findTransactionMethods(serviceClass);
Lookup lookup = MethodHandles.lookup();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code uses relative lookup (relative to this class) but does not need it (it
doesn't access any private or package-private members, for instance, which such call-site sensitive
lookup provides). Why don't we use
publicLookup (likely, 'relative' to the defining class to satisfy class-loading constraints
we bumped into earlier with protobuf reflective serializer)?

private static void checkDuplicates(Map<Integer, Method> transactionMethods, int transactionId,
Class<?> serviceClass, Method method) {
if (transactionMethods.containsKey(transactionId)) {
String errorMessage = String.format("Service %s has more than one transaction with the same"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Nit): this one is actually first (given the order of traversal). Also, variable.

Suggested change
String errorMessage = String.format("Service %s has more than one transaction with the same"
String firstMethodName = transactionMethods.get(transactionId).getName();
String errorMessage = String.format("Service %s has more than one transaction with the same"
+ " id (%s): first: %s; second: %s",
serviceClass.getName(), transactionId, firstMethodName, method.getName());

static final int TRANSACTION_ID_2 = 2;

@TransactionMethod(TRANSACTION_ID)
public void transactionMethod(byte[] arguments, TransactionContext context) {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this case (the present duplicates the annotation which isn't something that shall happen, I think):

  interface ServiceInterface {

    @TransactionMethod(1) // <-- Annotation is present on the interface
    void transactionMethod(byte[] arguments, TransactionContext context);
  }

  class ServiceInterfaceImpl implements ServiceInterface {

    @Override // <-- NO duplicate TransactionMethod annotation!
    public void transactionMethod(byte[] arguments, TransactionContext context) {}

  }

I think it is better to offload this to a sep. issue because there are more to that: e.g., what if an interface method and the implementation method have different @TransactionMethods?

Please document the considerations in a new issue.

Copy link
Contributor Author

@MakarovS MakarovS Dec 16, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created ECR-3989


@TransactionMethod(TRANSACTION_ID_2)
@SuppressWarnings("WeakerAccess") // Should be accessible
public void transactionMethod2(byte[] arguments, TransactionContext context) {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bike-shed target (@slowli , @bullet-tooth , @MakarovS, @vitvakatu )! Shall we require the arguments to be in order:

  1. (MessageT arguments, TransactionContext context)
  2. (TransactionContext context, MessageT arguments)
  3. Allow any (mind extra impl. complexity!)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is no need in arbitrary order. The either one works, but Rust runtime uses context, arguments one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with allowing any order, there'd be a couple more lines of code in implementation, but less unnecessary restrictions for users.
Created ECR-3990

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it is not badly needed, and any could work: in some cases, the arguments is the first argument used (e.g., to check preconditions); in some cases, the context (e.g., to instantiate the schema and check the preconditions depending on the state, or to just update it).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No particular preference. From the unification perspective, context, arguments ordering makes sense, but AFAIK, this is not the Java way™. Since reflection is used to handle the processing, restricting the ordering of args is not necessary. (Further, it could make sense to allow to extract commonly used parts of the context into args, e.g., the service schema and the caller.)

@dmitry-timofeev dmitry-timofeev merged commit 1e322c6 into master Dec 17, 2019
@dmitry-timofeev dmitry-timofeev deleted the ECR-3923 branch December 17, 2019 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants