Skip to content

Commit 2a0387a

Browse files
committed
Document transaction management variants.
1 parent 29f8337 commit 2a0387a

File tree

1 file changed

+255
-0
lines changed
  • spring-graphql-docs/modules/ROOT/pages

1 file changed

+255
-0
lines changed

spring-graphql-docs/modules/ROOT/pages/data.adoc

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,258 @@ Spring for GraphQL defines a `SortStrategy` to create `Sort` from GraphQL argume
471471
`AbstractSortStrategy` implements the contract with abstract methods to extract the sort
472472
direction and properties. To enable support for `Sort` as a controller method argument,
473473
you need to declare a `SortStrategy` bean.
474+
475+
476+
477+
[[data.transaction-management]]
478+
== Transaction Management
479+
480+
At some point when working with data then transactional boundaries start to matter.
481+
GraphQL itself does not define any transaction semantics, so it is up to the server
482+
and your application to decide how to handle transactions.
483+
484+
Spring Data repositories use implicit transactions for individual operations resulting
485+
in starting and commiting a transaction for each repository method call. This is the
486+
normal mode of operation for most NoSQL databases but not necessarily what you
487+
would want for relational databases.
488+
489+
You have several options how you could manage transactions in a GraphQL server:
490+
491+
1. <<data.transaction-management.transactional-service-methods,Use transactional service methods>>
492+
2. <<data.transaction-management.instrumentation,Using a `Instrumentation` that manages transactions programmatically>>
493+
3. <<data.transaction-management.execution-strategy,Implement a custom `ExecutionStrategy` that manages transactions programmatically>>
494+
495+
We generally recommend isolating transaction management in the controller or service.
496+
Any extended use (such as batch loading) might have their own requirements to transaction
497+
management and so you might want to consider the other two options.
498+
499+
[[data.transaction-management.transactional-service-methods]]
500+
=== Transactional Service Methods
501+
502+
Use Spring's `@Transactional` annotation on service methods that are
503+
invoked from your `@QueryMapping` controller method. This is the recommended approach
504+
as it gives you full control over the transaction boundaries with a clearly defined
505+
entrypoint, for example:
506+
507+
[source,java,indent=0,subs="verbatim,quotes"]
508+
----
509+
@Controller
510+
public class AccountController {
511+
512+
@QueryMapping
513+
@Transactional
514+
public Account accountById(@Argument String id) {
515+
... // fetch the entire account object within a single transaction
516+
}
517+
}
518+
----
519+
520+
A transaction spans from entering the `accountById` method until it returns meaning
521+
that the entire object must be materialized within that method. Taking GraphQL's core
522+
design principles into account, this is not idiomatic as it limits the ability of the
523+
client to drive the query and forces the server to load the entire object graph upfront.
524+
525+
Another aspect to consider is that subsequent data fetching for nested fields is not part
526+
of the transactional method `accountById` as the transaction is cleaned up after leaving
527+
the method.
528+
529+
[source,java,indent=0,subs="verbatim,quotes"]
530+
----
531+
@Controller
532+
public class AccountController {
533+
534+
@QueryMapping
535+
@Transactional
536+
public Account accountById(@Argument String id) {
537+
... // fetch the account within a transaction
538+
}
539+
540+
@SchemaMapping
541+
@Transactional
542+
public Person person(Account account) {
543+
... // fetching the person within a separate transaction
544+
}
545+
}
546+
----
547+
548+
Using `@Transactional` on multiple controller methods is possible and in fact
549+
recommended when applying mutations. Any transactional queries that would resolve nested
550+
fields fall into their own transaction.
551+
552+
You can replace Spring's `@Transactional` with `TransactionOperations` or
553+
`TransactionalOperator` if you wish to manage the transaction programmatically instead
554+
of using Spring's aspect-oriented programming (AOP) support for transactions.
555+
556+
557+
[[data.transaction-management.instrumentation]]
558+
=== Transactional Instrumentation
559+
560+
GraphQL Java's `Instrumentation` contract allows you to hook into the execution lifecycle
561+
at various stages. The Instrumentation SPI was designed with observability in mind, yet it
562+
serves as execution-agnostic extension points regardless of whether you're using synchronous
563+
reactive, or any other asynchronous form to invoke data fetchers.
564+
565+
Spanning an instrumentation over the entire execution creates an enclosing transaction.
566+
Ideally, the underlying `ExecutionStrategy` runs `DataFetcher` invocations serially so
567+
that all invocations are executed on the same `Thread`. This is mandatory: Synchronous
568+
transaction management uses `ThreadLocal` state to allow participation in transactions.
569+
570+
Another aspect of creating an outer transaction is that it spans across the entire execution.
571+
All `@SchemaMapping` controller methods participate in the transaction regardless whether
572+
they are invoked for the root, nested fields, or as part of a mutation. When participating
573+
in an ongoing transaction, transactional controller or service methods can declare
574+
transactional attributes such as propagation behavior `REQUIRES_NEW` to start
575+
a new transaction.
576+
577+
An example transactional `Instrumentation` must start a transaction before execution and
578+
clean up the transaction after execution completes. An example instrumentation could look
579+
like as follows:
580+
581+
[source,java,indent=0,subs="verbatim,quotes"]
582+
----
583+
class TransactionalInstrumentation implements Instrumentation {
584+
585+
private final Log logger = LogFactory.getLog(getClass());
586+
587+
private final PlatformTransactionManager txManager;
588+
private final TransactionDefinition definition;
589+
590+
TransactionalInstrumentation(PlatformTransactionManager txManager, TransactionDefinition definition) {
591+
this.transactionManager = transactionManager;
592+
this.definition = definition;
593+
}
594+
595+
@Override
596+
public @Nullable InstrumentationContext<ExecutionResult> beginExecution(
597+
InstrumentationExecutionParameters parameters, InstrumentationState state) {
598+
599+
TransactionStatus status = this.transactionManager.getTransaction(definition);
600+
601+
return SimpleInstrumentationContext.whenCompleted((result, t) -> {
602+
if (t != null) {
603+
rollbackOnException(status, t);
604+
} else {
605+
for (GraphQLError error : result.getErrors()) {
606+
if (error instanceof ExceptionWhileDataFetching e) {
607+
rollbackOnException(status, e.getException());
608+
return;
609+
}
610+
}
611+
this.transactionManager.commit(status);
612+
}
613+
});
614+
}
615+
616+
private void rollbackOnException(TransactionStatus status, Throwable ex) throws TransactionException {
617+
618+
logger.debug("Initiating transaction rollback on application exception", ex);
619+
try {
620+
this.transactionManager.rollback(status);
621+
} catch (TransactionSystemException ex2) {
622+
logger.error("Application exception overridden by rollback exception", ex);
623+
ex2.initApplicationException(ex);
624+
throw ex2;
625+
} catch (RuntimeException | Error ex2) {
626+
logger.error("Application exception overridden by rollback exception", ex);
627+
}
628+
}
629+
}
630+
----
631+
632+
This implementation repeats parts that reside in `TransactionTemplate` for proper
633+
transaction management. Another aspect to consider is that the above implementation relies
634+
on `ExceptionWhileDataFetching` that is only available if the underlying
635+
`ExecutionStrategy` uses `SimpleDataFetcherExceptionHandler`. By default, Spring GraphQL
636+
falls back to an internal `GraphQLError` that doesn't expose the original exception.
637+
638+
If there is already an `Instrumentation` configured, then you need to go a step further
639+
and use the `ExecutionStrategy` method.
640+
641+
[[data.transaction-management.execution-strategy]]
642+
=== Transactional `ExecutionStrategy`
643+
644+
Implementing an own transactional `ExecutionStrategy` is similar to the `Instrumentation`
645+
approach, but it gives you more control over the execution.
646+
647+
It can also serve as good entry point to implement custom directives that allow clients
648+
specifying transactional attributes through directives or using directives in your schema
649+
to demarcate transactional boundaries for certain queries or mutations.
650+
651+
An `ExecutionStrategy` provides full control over the execution and opens a multitude
652+
of possibilities over how to communicate failed transactions or errors during transaction
653+
cleanup back to the client.
654+
655+
The following block shows a rather simple example implementation without considering
656+
all invariants of exception handling:
657+
658+
[source,java,indent=0,subs="verbatim,quotes"]
659+
----
660+
static class TransactionalExecutionStrategy extends AsyncSerialExecutionStrategy {
661+
662+
protected final Log logger = LogFactory.getLog(getClass());
663+
664+
private final PlatformTransactionManager transactionManager;
665+
private final TransactionDefinition definition;
666+
667+
public TransactionalExecutionStrategy(PlatformTransactionManager transactionManager,
668+
TransactionDefinition definition) {
669+
this.transactionManager = transactionManager;
670+
this.definition = definition;
671+
}
672+
673+
@Override
674+
public CompletableFuture<ExecutionResult> execute(ExecutionContext executionContext,
675+
ExecutionStrategyParameters parameters) throws NonNullableFieldWasNullException {
676+
677+
TransactionStatus status = this.transactionManager.getTransaction(definition);
678+
try {
679+
return super.execute(executionContext, parameters).whenComplete((result, exception) -> {
680+
681+
if (exception != null) {
682+
rollbackOnException(status, exception);
683+
} else {
684+
685+
for (GraphQLError error : result.getErrors()) {
686+
if (error instanceof ExceptionWhileDataFetching e) {
687+
rollbackOnException(status, e.getException());
688+
return;
689+
}
690+
}
691+
692+
this.transactionManager.commit(status);
693+
}
694+
});
695+
} catch (RuntimeException | Error ex) {
696+
// Transactional code threw application exception -> rollback
697+
return CompletableFuture.failedFuture(rollbackOnException(status, ex));
698+
} catch (Throwable ex) {
699+
// Transactional code threw unexpected exception -> rollback
700+
return CompletableFuture.failedFuture(new UndeclaredThrowableException(rollbackOnException(status, ex),
701+
"TransactionCallback threw undeclared checked exception"));
702+
}
703+
}
704+
705+
/**
706+
* Perform a rollback, handling rollback exceptions properly.
707+
*
708+
* @param status object representing the transaction
709+
* @param ex the thrown application exception or error
710+
*/
711+
private Throwable rollbackOnException(TransactionStatus status, Throwable ex) {
712+
713+
logger.debug("Initiating transaction rollback on application exception", ex);
714+
try {
715+
this.transactionManager.rollback(status);
716+
} catch (TransactionSystemException ex2) {
717+
logger.error("Application exception overridden by rollback exception", ex);
718+
ex2.initApplicationException(ex);
719+
return ex2;
720+
} catch (RuntimeException | Error ex2) {
721+
logger.error("Application exception overridden by rollback exception", ex);
722+
return ex2;
723+
}
724+
725+
return ex;
726+
}
727+
}
728+
----

0 commit comments

Comments
 (0)