-
Notifications
You must be signed in to change notification settings - Fork 30
Add transaction methods extraction class [ECR-3923] #1274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
MethodHandle methodHandle = lookup.unreflect(transactionMethod); | ||
assertThat(transactions).hasSize(1); | ||
MethodHandle actualMethodHandle = transactions.get(ValidService.TRANSACTION_ID); | ||
assertThat(actualMethodHandle.toString()).isEqualTo(methodHandle.toString()); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- There are no tests for extraction of annotated methods (class hierarchies, annotation applied to interface methods — e.g., our QaService).
- There is no actual working invocation of the methods.
- 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: |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this 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. |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); | ||
} | ||
|
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
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) {} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) {} |
There was a problem hiding this comment.
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:
- (MessageT arguments, TransactionContext context)
- (TransactionContext context, MessageT arguments)
- Allow any (mind extra impl. complexity!)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.)
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