Transactions
Atomicity guarantee since v6.3.0
Transaction support exists since UltiTools-API v6.2.0. All three storage backends became atomic in v6.3.0.
Through v6.2.5, transaction(...) was atomic only on the JSON backend: MySQL and SQLite ran the callable with no transaction manager attached, so each write committed as it executed. Starting in v6.3.0, all three storage backends attach a real transaction manager, and the transfer pattern below is atomic everywhere.
UltiTools provides programmatic transaction support through the DataOperator interface. Transactions ensure that a group of operations either all succeed or all roll back on failure.
Basic Usage
Void Transaction
Use transaction(Runnable) for operations that don't return a value:
DataOperator<AccountEntity> dataOperator = plugin.getDataOperator(AccountEntity.class);
dataOperator.transaction(() -> {
AccountEntity from = dataOperator.query()
.where("playerId").eq(fromPlayer).first();
AccountEntity to = dataOperator.query()
.where("playerId").eq(toPlayer).first();
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
try {
dataOperator.update(from);
dataOperator.update(to);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
});If any operation within the transaction throws an exception, all changes are rolled back.
Transaction with Return Value
Use transaction(Callable<R>) when you need to return a result:
DataOperator<AccountEntity> dataOperator = plugin.getDataOperator(AccountEntity.class);
try {
double newBalance = dataOperator.transaction(() -> {
AccountEntity account = dataOperator.query()
.where("playerId").eq(playerUuid).first();
account.setBalance(account.getBalance() + depositAmount);
dataOperator.update(account);
return account.getBalance();
});
player.sendMessage("New balance: " + newBalance);
} catch (Exception e) {
player.sendMessage("Transaction failed: " + e.getMessage());
}Batch Operations
The DataOperator interface provides batch methods that automatically wrap operations in a transaction:
insertAll
Insert multiple entities atomically:
List<HomeEntity> homes = new ArrayList<>();
homes.add(HomeEntity.builder().name("base").playerId(uuid).build());
homes.add(HomeEntity.builder().name("mine").playerId(uuid).build());
homes.add(HomeEntity.builder().name("farm").playerId(uuid).build());
dataOperator.insertAll(homes); // All inserted or noneupdateAll
Update multiple entities atomically:
List<AccountEntity> accounts = dataOperator.getAll();
for (AccountEntity account : accounts) {
account.setBalance(account.getBalance() * 1.05); // 5% interest
}
dataOperator.updateAll(accounts); // All updated or noneHow It Works
Transactions work transparently across all storage backends:
| Backend | Mechanism |
|---|---|
| MySQL / SQLite | Uses JDBC transactions (Connection.setAutoCommit(false), commit/rollback) |
| JSON | Uses snapshot-based rollback (copies data before changes, restores on failure) |
You don't need to know which backend is active — the same transaction API works for all storage types.
Batch methods are atomic too, on every backend
insertAll and updateAll wrap their work in the same transaction mechanism, including the two direct JDBC statement paths that used to bypass it: a batch insert whose third row violates a primary key constraint leaves zero rows on any backend, not just JSON.
Whole-cache rollback on the JSON backend
JSON rollback restores an operator's entire in-memory cache from a snapshot, not individual entities. This affects Propagation.REQUIRES_NEW and NOT_SUPPORTED when both scopes write to the same operator.
The JSON backend's rollback is snapshot-based: on first touch inside a transaction, an operator's entire in-memory cache is deep-copied, and on failure the whole cache is restored from that snapshot. This is whole-cache granularity, not a per-entity undo.
It matters specifically for Propagation.REQUIRES_NEW and NOT_SUPPORTED (covered later on this page): an inner scope's independence from an outer one is only observable when the two scopes touch different DataOperator instances. If both write to the same operator, the outer scope's eventual rollback discards the inner scope's already-committed write too.
Complete Example
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.interfaces.DataOperator;
@Service
public class EconomyService {
@Autowired
private UltiToolsPlugin plugin;
public boolean transfer(String fromUuid, String toUuid, double amount) {
DataOperator<AccountEntity> dataOperator =
plugin.getDataOperator(AccountEntity.class);
try {
dataOperator.transaction(() -> {
AccountEntity from = dataOperator.query()
.where("playerId").eq(fromUuid).first();
AccountEntity to = dataOperator.query()
.where("playerId").eq(toUuid).first();
if (from == null || to == null) {
throw new RuntimeException("Account not found");
}
if (from.getBalance() < amount) {
throw new RuntimeException("Insufficient balance");
}
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
try {
dataOperator.update(from);
dataOperator.update(to);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
});
return true;
} catch (Exception e) {
// Transaction rolled back automatically
return false;
}
}
}TIP
For simple single-entity operations, you don't need transactions. Transactions are most useful when you need to ensure multiple operations succeed or fail together.
Declarative Transactions
Wired for the first time in v6.3.0
Through v6.2.5, @Transactional was never read: no interceptor ran, so annotated methods behaved exactly like unannotated ones. v6.3.0 wires it end to end and rejects unsupportable beans at load time.
The aop package that creates the proxies was not referenced anywhere outside itself through v6.2.5: no bean post processor was registered and TransactionInterceptor was never instantiated, so an annotated method took exactly the same path as an unannotated one, with no commit, no rollback, and no log line. On v6.2.5, use the programmatic form shown earlier on this page instead, or drop the annotation.
Starting in v6.3.0, @Transactional is wired end to end on all three storage backends: SQLite and MySQL through a per-plugin JdbcTransactionManager, JSON through a snapshot-based JsonTransactionManager. A bean that declares @Transactional, including one that merely inherits or extends a class that does, is rejected at load time if the framework cannot supply a transaction manager for it, rather than silently running untransacted.
The @Transactional annotation provides declarative transaction management on service methods. This approach is cleaner than programmatic transactions and integrates seamlessly with the IoC container.
Prerequisites
The @Transactional annotation only works on methods within @Service beans, since transactions are implemented via generated subclass proxies (ByteBuddy since v6.3.0; the earlier CGLIB engine had the same subclass-proxy shape):
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
@Service
public class PaymentService {
@Transactional
public void processPayment(String playerId, double amount) {
// This method will be wrapped in a transaction automatically
}
}Basic Usage
Simply add @Transactional to a service method:
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;
@Service
public class AccountService {
@Autowired
private UltiToolsPlugin plugin;
@Transactional
public void transfer(String fromPlayerId, String toPlayerId, double amount) {
DataOperator<AccountEntity> dataOperator =
plugin.getDataOperator(AccountEntity.class);
AccountEntity from = dataOperator.query()
.where("playerId").eq(fromPlayerId).first();
AccountEntity to = dataOperator.query()
.where("playerId").eq(toPlayerId).first();
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
try {
dataOperator.update(from);
dataOperator.update(to);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}The method executes within a transaction that commits on success or rolls back on exception.
Annotation Attributes
The @Transactional annotation accepts several configuration options:
| Attribute | Type | Default | Description |
|---|---|---|---|
propagation | Propagation | REQUIRED | Transaction propagation behavior |
isolation | Isolation | DEFAULT | Isolation level |
timeout | int | -1 | Timeout in seconds (-1 = no timeout) |
readOnly | boolean | false | Mark transaction as read-only for optimizations |
rollbackFor | Class[] | {} | Exception types that trigger rollback |
noRollbackFor | Class[] | {} | Exception types that do NOT trigger rollback |
Propagation Modes
NESTED removed, not merely unimplemented
Propagation.NESTED no longer exists as of v6.3.0, and referencing it fails to compile. REQUIRES_NEW and NOT_SUPPORTED now genuinely suspend the active transaction on every backend.
Through v6.2.5 the interceptor never ran at all, so this table described intended design, not observed behavior.
NESTED was dropped on controllability, not impossibility. It maps cleanly to Connection.setSavepoint(), but savepoint behavior depends on whichever sqlite-jdbc version the server's own Paper build happens to ship, and that is not something this project can pin or test across.
The propagation attribute controls how the method behaves when called within an existing transaction. As of v6.3.0 there are exactly six values, matching Jakarta Transactions 2.0's TxType set:
| Mode | Behavior |
|---|---|
REQUIRED (default) | Joins the current transaction, or creates a new one if none exists |
REQUIRES_NEW | Always creates a new transaction, suspending any existing one |
SUPPORTS | Joins the current transaction if one exists; executes non-transactionally otherwise |
NOT_SUPPORTED | Always executes without a transaction, suspending any existing one |
MANDATORY | Requires an existing transaction; throws an exception if none exists |
NEVER | Must not execute within a transaction; throws an exception if one exists |
Example with REQUIRES_NEW:
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Propagation;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;
@Service
public class AuditService {
@Autowired
private UltiToolsPlugin plugin;
// This method always gets its own transaction, even if called from another transactional method
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAudit(String message) {
DataOperator<AuditLogEntity> dataOperator =
plugin.getDataOperator(AuditLogEntity.class);
AuditLogEntity log = AuditLogEntity.builder()
.message(message)
.timestamp(System.currentTimeMillis())
.build();
dataOperator.insert(log);
}
}Isolation Levels
The isolation attribute controls the isolation level for the transaction:
| Level | Prevents | Database Support |
|---|---|---|
DEFAULT | Uses database default | All databases |
READ_UNCOMMITTED | None (dirty reads possible) | Most databases |
READ_COMMITTED | Dirty reads | Most databases |
REPEATABLE_READ | Dirty reads, non-repeatable reads | Most databases |
SERIALIZABLE | All consistency issues | All databases |
Higher isolation levels provide stronger consistency guarantees but may impact performance. Use SERIALIZABLE only when strict isolation is critical:
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalTransfer(String from, String to, double amount) {
// Ensures complete isolation from concurrent transactions
}Custom Rollback Rules
rollbackFor adds to the default rule, not replaces it
As of v6.3.0, rollbackFor adds to the default rollback-on-RuntimeException/Error rule rather than replacing it.
An exception matching neither rollbackFor nor noRollbackFor still falls through to the default rule, so @Transactional(rollbackFor = BusinessException.class) still rolls back on an unrelated exception like a NullPointerException. Through v6.2.5, a non-empty rollbackFor replaced the default rule entirely, so listing one custom type silently stopped rollback for every exception not on that list; that behavior is gone as of v6.3.0.
By default, @Transactional rolls back on any RuntimeException or Error. Use rollbackFor to trigger rollback for additional exceptions:
@Transactional(rollbackFor = BusinessException.class)
public void processOrder(Order order) throws BusinessException {
if (!order.isValid()) {
throw new BusinessException("Invalid order"); // Triggers rollback
}
// Process order...
}Use noRollbackFor to prevent rollback for specific exceptions:
@Transactional(noRollbackFor = WarningException.class)
public void importData(String source) throws WarningException {
try {
// Perform import...
} catch (MinorIssueException e) {
throw new WarningException("Non-critical issue, transaction commits");
}
}When an exception matches both rollbackFor and noRollbackFor, the rule whose listed class is the shallower inheritance-depth match to the thrown exception wins; on an exact-depth tie — including the same class listed in both arrays — the transaction rolls back:
class OrderException extends RuntimeException { }
class ValidationException extends OrderException { }
@Transactional(rollbackFor = ValidationException.class, noRollbackFor = OrderException.class)
public void processOrder(Order order) throws ValidationException {
if (!order.hasShippingAddress()) {
throw new ValidationException("missing shipping address"); // rolls back:
// ValidationException is a depth-0 match for rollbackFor, a depth-1 match for
// noRollbackFor (one step up to OrderException) — the shallower match wins.
}
}Read-Only Transactions
Mark read-only query methods with readOnly = true to allow the database to apply optimizations:
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import java.util.List;
import java.util.UUID;
@Service
public class PlayerRepository {
@Autowired
private UltiToolsPlugin plugin;
@Transactional(readOnly = true)
public List<PlayerEntity> getAllPlayers() {
return plugin.getDataOperator(PlayerEntity.class).getAll();
}
@Transactional(readOnly = true)
public PlayerEntity getPlayerById(UUID uuid) {
return plugin.getDataOperator(PlayerEntity.class).query()
.where("uuid").eq(uuid.toString()).first();
}
}Timeout Configuration
Per-statement bound, not a method-wide wall clock
timeout applies setQueryTimeout to each JDBC statement inside the transaction, against a shared budget that shrinks as the transaction progresses. It never interrupts non-database work, and it fails outright on the JSON backend.
timeout is enforced as a JDBC setQueryTimeout on every statement issued inside the transaction, against a shared deadline that starts when the transaction begins. Each statement gets whatever time is left in that budget when it is prepared, floored at 1 second so an exhausted budget still fails fast. This is not a bound on the method body as a whole: non-database work inside the method, such as a slow computation or an outbound network call, is never interrupted, because plain JDBC has no mechanism to cancel work already in flight.
On SQLite and MySQL, timeout is enforced as described. On the JSON backend, a positive timeout fails the transaction outright, because JsonTransactionManager has no statement to bound: its rollback is a cache-snapshot restore, not a JDBC operation.
Set a timeout (in seconds) for long-running transactions:
@Transactional(timeout = 30)
public void bulkProcessing() {
// Every statement issued while this transaction is open gets a query timeout equal to
// whatever remains of the 30-second budget when that statement is prepared.
List<DataEntity> all = getDataOperator().getAll();
for (DataEntity entity : all) {
processEntity(entity);
}
}A value of -1 (default) means no timeout — the interceptor never calls setTimeout for it, on any backend.
Important Limitations
Method eligibility:
private,static, andfinalmethods cannot be transactional — each is dispatched in a way the proxy cannot intercept. A package-private method is also ineligible when it is declared in a different package than the bean class.protectedand same-package package-private methods are eligible.Self-invocation is intercepted, not bypassed: unlike delegate-based proxy frameworks, this framework's generated proxy is a subclass of the bean itself, not a separate object wrapping it — so a call to
this.transactionalMethod()from another method in the same class dispatches virtually onto the proxy's override and is intercepted, exactly like a call made from outside the class. There is no need to inject the service into itself or call through the container to get transaction behavior on a same-class call.Non-final classes: The class cannot be
final(subclass-proxy limitation). The same applies to methods — they must be overridable.
Programmatic vs Declarative
Both approaches achieve the same result. Choose based on your use case:
Use Programmatic Transactions (dataOperator.transaction()) when:
- You need fine-grained control over transaction boundaries
- The transaction spans multiple service calls
- You're working outside a
@Servicebean - You need to handle nested transactions manually
Use Declarative Transactions (@Transactional) when:
- You want cleaner, more readable service layer code
- A single method performs all the operations that must be atomic
- You want to leverage AOP for cross-cutting concerns
- You're building service classes with multiple transactional methods
Example combining both:
package com.ultikits.docs.transactions;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;
@Service
public class ComplexService {
@Autowired
private UltiToolsPlugin plugin;
// Declarative for simple method-level transactions
@Transactional
public void simpleOperation() {
// Automatic transaction management
}
// Programmatic for complex multi-step workflows
public void complexWorkflow() {
DataOperator<AccountEntity> dataOp = plugin.getDataOperator(AccountEntity.class);
// Explicit transaction with fine-grained control
dataOp.transaction(() -> {
// Multiple coordinated operations
step1();
step2();
step3();
});
}
private void step1() { }
private void step2() { }
private void step3() { }
}