Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class EntityMutationLedger {
private final Map<EntityKey, List<TraceNode>> traceChains = new TreeMap<>();
private final Map<EntityKey, Long> originalVersions = new TreeMap<>();
private boolean committedStateRequiresReload;
private boolean transactionOutcomeUnknownRequiresReload;

// --- Change Set Stack ---

Expand Down Expand Up @@ -47,8 +48,17 @@ public void markCommittedStateRequiresReload() {
committedStateRequiresReload = true;
}

/** Prevents a stale graph from replaying a write whose outcome is already committed. */
/** A provider could not confirm commit or rollback; this graph must not be replayed. */
public void markTransactionOutcomeUnknownRequiresReload() {
transactionOutcomeUnknownRequiresReload = true;
}

/** Prevents replay of a graph whose persistence outcome needs a fresh read. */
public void requireSaveable() {
if (transactionOutcomeUnknownRequiresReload) {
throw new TeaQLRuntimeException(
"TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED: reload the entity graph before another save");
}
if (committedStateRequiresReload) {
throw new TeaQLRuntimeException(
"COMMITTED_STATE_RELOAD_REQUIRED: reload the entity graph before another save");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.teaql.core;

/** A provider could not confirm whether a transaction committed; reload before retrying. */
public final class TransactionOutcomeUnknownException extends TeaQLRuntimeException {
private static final String MESSAGE = "TRANSACTION_OUTCOME_UNKNOWN: the provider could not confirm the transaction result; "
+ "reload the affected graph and committed events before any retry";
private final Error originalError;

public TransactionOutcomeUnknownException() {
this(null);
}

/** Retain a business Error without exposing it as a diagnostic cause. */
public TransactionOutcomeUnknownException(Error originalError) {
super(MESSAGE);
this.originalError = originalError;
}

/** The graph runtime rethrows this Error only after fencing the mutation ledger. */
public Error originalError() {
return originalError;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.teaql.provider.jdbc;

import io.teaql.core.TransactionOutcomeUnknownException;
import io.teaql.dataservice.sql.SqlExecutionAdapter;
import io.teaql.dataservice.sql.SqlRowMapper;

Expand Down Expand Up @@ -437,58 +438,93 @@ public void executeInTransaction(Runnable action) {
throw new RuntimeException("JDBC transaction failed", failure);
}
boolean committed = false;
boolean outcomeUnknown = false;
boolean rollbackUnconfirmed = false;
try {
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
transactionConnection.set(connection);
transactionRollbackOnly.set(false);
try {
action.run();
if (Boolean.TRUE.equals(transactionRollbackOnly.get())) {
throw new IllegalStateException(
"JDBC transaction is rollback-only after a failed operation");
try {
action.run();
if (Boolean.TRUE.equals(transactionRollbackOnly.get())) {
throw new IllegalStateException(
"JDBC transaction is rollback-only after a failed operation");
}
} catch (RuntimeException | Error failure) {
try {
connection.rollback();
} catch (SQLException | RuntimeException | Error rollbackFailure) {
outcomeUnknown = true;
rollbackUnconfirmed = true;
warnTransactionCleanup("rollback after failed action", false, true);
if (failure instanceof Error) {
// The graph runtime must fence the ledger before
// rethrowing the original Error. A suppressed marker
// is unreliable because Error may disable suppression.
throw new TransactionOutcomeUnknownException((Error) failure);
}
if (rollbackFailure instanceof Error) {
throw new TransactionOutcomeUnknownException((Error) rollbackFailure);
}
throw new TransactionOutcomeUnknownException();
}
throw failure;
}
connection.commit();
committed = true;
} catch (RuntimeException | Error failure) {
try {
connection.rollback();
} catch (SQLException | RuntimeException rollbackFailure) {
// Keep the original failure. The JDBC message may contain
// sensitive connection details and is not safe to expose.
failure.addSuppressed(new IllegalStateException("JDBC rollback failed"));
connection.commit();
committed = true;
} catch (SQLException | RuntimeException | Error commitFailure) {
// The database may have committed before the driver lost the
// acknowledgement. Conversely, restoring autoCommit without a
// rollback can commit a transaction the driver never attempted.
outcomeUnknown = true;
warnTransactionCleanup("commit confirmation", false, true);
try {
connection.rollback();
} catch (SQLException | RuntimeException | Error rollbackFailure) {
rollbackUnconfirmed = true;
warnTransactionCleanup("rollback after unconfirmed commit", false, true);
}
throw commitFailure instanceof Error
? new TransactionOutcomeUnknownException((Error) commitFailure)
: new TransactionOutcomeUnknownException();
}
throw failure;
} finally {
transactionConnection.remove();
transactionRollbackOnly.remove();
try {
connection.setAutoCommit(autoCommit);
} catch (SQLException | RuntimeException cleanupFailure) {
// A successful commit is already durable. A cleanup error
// must not make the caller replay that business mutation.
warnTransactionCleanup("autoCommit restoration", committed);
// A confirmed rollback leaves no pending write to commit. If
// rollback itself failed, changing autoCommit could commit it.
if (!rollbackUnconfirmed) {
try {
connection.setAutoCommit(autoCommit);
} catch (SQLException | RuntimeException | Error cleanupFailure) {
// A successful commit is already durable. A cleanup error
// must not make the caller replay that business mutation.
warnTransactionCleanup("autoCommit restoration", committed, outcomeUnknown);
}
}
}
} catch (SQLException e) {
throw new RuntimeException("JDBC transaction failed", e);
} finally {
try {
connection.close();
} catch (SQLException | RuntimeException cleanupFailure) {
warnTransactionCleanup("connection close", committed);
} catch (SQLException | RuntimeException | Error cleanupFailure) {
warnTransactionCleanup("connection close", committed, outcomeUnknown);
}
}
}

private static void warnTransactionCleanup(String operation, boolean committed) {
private static void warnTransactionCleanup(String operation, boolean committed, boolean outcomeUnknown) {
try {
LOG.warning(committed
? "JDBC transaction committed; " + operation + " failed"
: "JDBC transaction unsuccessful; " + operation + " failed");
} catch (RuntimeException ignored) {
String state = committed ? "committed" : outcomeUnknown ? "outcome unknown" : "unsuccessful";
LOG.warning("JDBC transaction " + state + "; " + operation + " failed");
} catch (RuntimeException | Error ignored) {
// A user-installed logging handler must not turn a committed write
// back into an apparently retryable transaction failure.
// back into an apparently retryable transaction failure, even if it
// throws an Error with suppressed diagnostics disabled.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,12 @@ public void saveGraph(UserContext context, Entity entity) {
completed = executeLedgerPlan(
context, entityMutationLedger, mutationExecutor, realEntities, governance, intent, traceScopes, graphScope);
}
} catch (TransactionOutcomeUnknownException failure) {
// The JDBC commit acknowledgement can fail after durable commit.
// Restoring the old version would make this graph appear retryable.
entityMutationLedger.markTransactionOutcomeUnknownRequiresReload();
if (failure.originalError() != null) throw failure.originalError();
throw failure;
} catch (RuntimeException | Error failure) {
restoreGraphPersistenceState(
entity,
Expand Down
Loading
Loading