Skip to content

Commit 2e683d3

Browse files
committed
Refine transaction documentation.
1 parent 2a0387a commit 2e683d3

File tree

1 file changed

+124
-205
lines changed
  • spring-graphql-docs/modules/ROOT/pages

1 file changed

+124
-205
lines changed

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

Lines changed: 124 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -477,252 +477,171 @@ you need to declare a `SortStrategy` bean.
477477
[[data.transaction-management]]
478478
== Transaction Management
479479

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.
480+
At some point when working with data atomicity and isolation of operations start to
481+
matter. These are both properties of transactions. GraphQL itself does not define any
482+
transaction semantics, so it is up to the server and your application to decide how to
483+
handle transactions.
483484

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.
485+
GraphQL and specifically GraphQL Java are designed to be non-opinionated about how data
486+
is fetched. A core property of GraphQL is that clients drive the query; Fields
487+
can be resolved independently of their original source to allow for composition.
488+
A reduced fieldset can require less data to be fetched resulting in better performance.
488489

489-
You have several options how you could manage transactions in a GraphQL server:
490+
Applying the concept of distributed field resolution within transactions is not a good fit:
490491

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>>
492+
* Transactions keep a unit of work together resulting typically in fetching the entire
493+
object graph (like a typical object-relational mapper would behave) within a single
494+
transaction. This is at odds with GraphQL's core design to let the client drive queries.
495+
496+
* Keeping a transaction open across multiple data fetchers of which each one would
497+
fetch only its flat object mitigates the performance aspect and aligns with decoupled
498+
field resolution, but it can lead to long-running transactions that hold on to resources
499+
for longer than necessary.
500+
501+
Generally speaking, transactions are best applied to mutations that change state and not
502+
necessarily to queries that just read data. However, there are use cases where
503+
transactional reads are required.
504+
505+
GraphQL is designed to support multiple mutations within a single query. Depending on
506+
the use case, you might want to:
507+
508+
* Run each mutation within its own transaction.
509+
* Keep some mutations within a single transaction to ensure a consistent state.
510+
* Span a single transaction over all involved mutations.
511+
512+
Each approach requires a slightly different transaction management strategy.
513+
514+
By default, (and without any further instrumentation) Spring Data repositories use implicit
515+
transactions for individual operations resulting in starting and commiting a transaction
516+
for each repository method call. This is the normal mode of operation for most databases.
517+
518+
The following sections are outlining two different strategies to manage transactions in a
519+
GraphQL server:
520+
521+
1. <<data.transaction-management.transactional-service-methods,Transaction per Controller Method>>
522+
2. <<data.transaction-management.transactional-instrumentation,Spanning a Transaction programmatically over the entire request>>
494523

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.
498524

499525
[[data.transaction-management.transactional-service-methods]]
500526
=== Transactional Service Methods
501527

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:
528+
The simplest approach to manage transactions is to use Spring's Transaction Management
529+
together with `@MutationMapping` controller methods (or any other `@SchemaMapping` method)
530+
for example:
506531

507-
[source,java,indent=0,subs="verbatim,quotes"]
532+
[tabs]
533+
======
534+
Declarative::
535+
+
536+
[source,java,indent=0,subs="verbatim,quotes,attributes",role="primary"]
508537
----
509538
@Controller
510539
public class AccountController {
511540
512-
@QueryMapping
541+
@MutationMapping
513542
@Transactional
514-
public Account accountById(@Argument String id) {
515-
... // fetch the entire account object within a single transaction
543+
public Account addAccount(@Argument AccountInput input) {
544+
// ...
516545
}
517546
}
518547
----
519548
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"]
549+
Programmatic::
550+
+
551+
[source,java,indent=0,subs="verbatim,quotes,attributes",role="secondary"]
530552
----
531553
@Controller
532554
public class AccountController {
533555
534-
@QueryMapping
535-
@Transactional
536-
public Account accountById(@Argument String id) {
537-
... // fetch the account within a transaction
538-
}
556+
private final TransactionOperations transactionOperations;
539557
540-
@SchemaMapping
541-
@Transactional
542-
public Person person(Account account) {
543-
... // fetching the person within a separate transaction
558+
@MutationMapping
559+
public Account addAccount(@Argument AccountInput input) {
560+
return transactionOperations.execute(status -> {
561+
// ...
562+
});
544563
}
545564
}
546565
----
566+
======
547567

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.
568+
A transaction spans from entering the `addAccount` method until it returns its return value.
569+
All invocations to transactional resources are part of the same transaction resulting in
570+
atomicity and isolation of the mutation.
569571

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.
572+
This is the recommended approach as it gives you full control over the transaction
573+
boundaries with a clearly defined entrypoint without the need to instrument GraphQL
574+
server infrastructure.
576575

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:
576+
Another aspect to consider is that subsequent data fetching for nested fields is not part
577+
of the transactional method `addAccount` as the transaction is cleaned up after leaving
578+
the method as shown below:
580579

581580
[source,java,indent=0,subs="verbatim,quotes"]
582581
----
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-
}
582+
@Controller
583+
public class AccountController {
594584
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-
});
585+
@MutationMapping
586+
@Transactional
587+
public Account addAccount(@Argument AccountInput input) { <1>
588+
// ...
614589
}
615590
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-
}
591+
@SchemaMapping
592+
@Transactional
593+
public Person person(Account account) { <2>
594+
... // fetching the person within a separate transaction
628595
}
629596
}
630597
----
598+
<1> The `addAccount` method invocation runs within its own transaction.
599+
<2> The `person` method invocation creates its own, separate transaction that is not
600+
tied to the `addAccount` method in case both methods were invoked as part of the same
601+
GraphQL request. A separate transaction comes with all possible drawbacks of not
602+
being part of the same transaction, such as non-repeatable reads or inconsistencies
603+
in case the data has been modified between the `addAcount` and `person` method invocations.
631604

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`
643605

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());
606+
[[data.transaction-management.transactional-instrumentation]]
607+
=== Transactional Instrumentation
663608

664-
private final PlatformTransactionManager transactionManager;
665-
private final TransactionDefinition definition;
609+
Applying a Transactional Instrumentation is a more advanced approach to span a
610+
transaction over the entire execution of a GraphQL request. By stating a transaction
611+
before the first data fetcher is invoked your application can ensure that all data
612+
fetchers can participate in the same transaction.
666613

667-
public TransactionalExecutionStrategy(PlatformTransactionManager transactionManager,
668-
TransactionDefinition definition) {
669-
this.transactionManager = transactionManager;
670-
this.definition = definition;
671-
}
614+
When instrumenting the server, you need to ensure an `ExecutionStrategy` runs
615+
`DataFetcher` invocations serially so that all invocations are executed on the same
616+
`Thread`. This is mandatory: Synchronous transaction management uses `ThreadLocal` state
617+
to allow participation in transactions. Considering `AsyncSerialExecutionStrategy` as
618+
starting point is a good choice as it executes data fetchers serially.
672619

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-
}
620+
You have two general options to implement transactional instrumentation:
704621

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-
----
622+
1. GraphQL Java's `Instrumentation` contract allows to hook into the execution lifecycle
623+
at various stages. The Instrumentation SPI was designed with observability in mind, yet it
624+
serves as execution-agnostic extension points regardless of whether you're using
625+
synchronous reactive, or any other asynchronous form to invoke data fetchers and is less
626+
opinionated in that regard.
627+
628+
2. An `ExecutionStrategy` provides full control over the execution and opens a variety
629+
of possibilities how to communicate failed transactions or errors during transaction
630+
cleanup back to the client. It can also serve as good entry point to implement custom
631+
directives that allow clients specifying transactional attributes through directives or
632+
using directives in your schema to demarcate transactional boundaries for certain queries
633+
or mutations.
634+
635+
When manually managing transactions, ensure to cleanup the transaction, that is either
636+
commiting or rolling back, after completing the unit of work.
637+
`ExceptionWhileDataFetching` can be a useful `GraphQLError` to obtain an underlying
638+
`Exception`. This error is constructed when using `SimpleDataFetcherExceptionHandler`.
639+
By default, Spring GraphQL falls back to an internal `GraphQLError` that doesn't expose
640+
the original exception.
641+
642+
Applying transactional instrumentation creates opportunities to rethink transaction
643+
participation: All `@SchemaMapping` controller methods participate in the transaction
644+
regardless whether they are invoked for the root, nested fields, or as part of a mutation.
645+
Transactional controller methods (or service methods within the invocation chain) can
646+
declare transactional attributes such as propagation behavior `REQUIRES_NEW` to start
647+
a new transaction if required.

0 commit comments

Comments
 (0)