Skip to content

Commit 29a79f5

Browse files
refactor(server): remove transactional comment debt
1 parent 591a093 commit 29a79f5

23 files changed

Lines changed: 54 additions & 189 deletions

.changeset/spring-contracts-hold.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"hephaestus": patch
33
---
44

5-
Rejects mentor chat requests without a message and integration connection requests without a provider kind before processing them.
5+
Rejects mentor chat requests missing a message and integration connection requests missing a provider kind.

server/openapi.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9769,7 +9769,6 @@ components:
97699769
<p><code>userInput</code> is intentionally a free-form map so per-kind ConnectionStrategy
97709770
implementations can dictate their own field schema (e.g. GitLab needs <code>pat</code> +
97719771
<code>group_id</code>; GitHub needs nothing because the install URL is server-configured).
9772-
Provider-specific input validation is the strategy's responsibility.
97739772
properties:
97749773
kind:
97759774
type: string

server/src/main/java/de/tum/cit/aet/hephaestus/agent/mentor/chat/MentorInFlightAccounting.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ public MentorInFlightAccounting(ChatMessageRepository chatMessageRepository, Llm
3535

3636
@Transactional(propagation = Propagation.REQUIRES_NEW)
3737
public boolean account(UUID messageId) {
38+
// Re-read here so a turn completed after the sweep query is not billed as abandoned.
3839
ChatMessage message = chatMessageRepository.findById(messageId).orElse(null);
3940
if (message == null || message.getStatus() != ChatMessage.Status.in_flight) return false;
4041
JsonNode existingMetadata = message.getMetadata();
4142
LlmPriceSnapshot price = MentorAdmissionMetadata.readPrice(existingMetadata);
4243
message.setStatus(ChatMessage.Status.interrupted);
4344
message.setMetadata(withAbandonedError(existingMetadata));
45+
// End the in-flight state before reading usage so the proxy accumulator can no longer change the totals.
4446
chatMessageRepository.saveAndFlush(message);
4547
MentorTurnLlmUsage observed = chatMessageRepository
4648
.findLlmUsageById(message.getId())

server/src/main/java/de/tum/cit/aet/hephaestus/agent/mentor/chat/MentorInFlightReaper.java

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,10 @@
1818
import org.springframework.stereotype.Component;
1919

2020
/**
21-
* Interrupts and accounts mentor turns abandoned by a crashed process. Nobody is left to report what
22-
* such a turn spent — no runner report, no in-process meter — so it is billed from the per-call totals
23-
* the LLM proxy wrote to its row, and only a turn with no recorded call at all is booked UNVERIFIABLE.
21+
* Interrupts and accounts mentor turns abandoned by a crashed process.
2422
*
25-
* <p>This sweep writes money, so it is deliberately per-turn rather than per-batch: the accounting collaborator
26-
* owns one turn in its own {@code REQUIRES_NEW} transaction. A batch rollback would also undo turns
27-
* already billed in it and leave their {@code in_flight} rows in place, and the partial unique index
28-
* would then refuse every further turn on those threads.
29-
*
30-
* <p>What prevents a double charge is the ledger's {@code (MENTOR_TURN, messageId, 0)} unique
31-
* constraint behind {@code ON CONFLICT … DO NOTHING}, not any in-process check. The per-turn re-read
32-
* decides which of the two writes is the CORRECT one: without it, this sweep could bill a turn that has
33-
* since finished and permanently shadow the normal path's real amount.
23+
* <p>Each turn is accounted independently so one failure cannot roll back previously accounted turns
24+
* or leave their in-flight rows blocking the thread.
3425
*/
3526
@ConditionalOnServerRole
3627
@Component

server/src/main/java/de/tum/cit/aet/hephaestus/integration/core/connection/ConnectionRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ Optional<Connection> findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
5050
IntegrationState state
5151
);
5252

53+
default Optional<Connection> findActive(long workspaceId, IntegrationKind kind) {
54+
return findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(workspaceId, kind, IntegrationState.ACTIVE);
55+
}
56+
5357
List<Connection> findByWorkspaceIdAndState(long workspaceId, IntegrationState state);
5458

5559
/**

server/src/main/java/de/tum/cit/aet/hephaestus/integration/core/connection/ConnectionService.java

Lines changed: 14 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ public class ConnectionService {
3939
private final ApplicationEventPublisher eventPublisher;
4040
private final SyncJobService syncJobService;
4141

42-
/**
43-
* Runs the pre-transition revoke/erase callback in its own {@code REQUIRES_NEW} transaction so a
44-
* failing erase cannot mark the lifecycle transaction rollback-only — see {@link #runRevokeIsolated}.
45-
*/
4642
private final TransactionTemplate revokeTransactionTemplate;
4743

4844
public ConnectionService(
@@ -64,11 +60,7 @@ public ConnectionService(
6460

6561
@Transactional(readOnly = true)
6662
public Optional<Connection> findActive(long workspaceId, IntegrationKind kind) {
67-
return connectionRepository.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
68-
workspaceId,
69-
kind,
70-
IntegrationState.ACTIVE
71-
);
63+
return connectionRepository.findActive(workspaceId, kind);
7264
}
7365

7466
/**
@@ -86,20 +78,8 @@ public List<Long> findWorkspaceIdsWithActiveConnection(IntegrationKind kind) {
8678
*/
8779
@Transactional(readOnly = true)
8880
public Optional<IntegrationKind> findActiveProviderKind(long workspaceId) {
89-
boolean github = connectionRepository
90-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
91-
workspaceId,
92-
IntegrationKind.GITHUB,
93-
IntegrationState.ACTIVE
94-
)
95-
.isPresent();
96-
boolean gitlab = connectionRepository
97-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
98-
workspaceId,
99-
IntegrationKind.GITLAB,
100-
IntegrationState.ACTIVE
101-
)
102-
.isPresent();
81+
boolean github = connectionRepository.findActive(workspaceId, IntegrationKind.GITHUB).isPresent();
82+
boolean gitlab = connectionRepository.findActive(workspaceId, IntegrationKind.GITLAB).isPresent();
10383
if (github && gitlab) {
10484
throw new IllegalStateException(
10585
"Workspace " +
@@ -116,11 +96,7 @@ public Optional<IntegrationKind> findActiveProviderKind(long workspaceId) {
11696
@Transactional(readOnly = true)
11797
public Optional<ConnectionConfig.GitHubAppConfig> findActiveGitHubAppConfig(long workspaceId) {
11898
return connectionRepository
119-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
120-
workspaceId,
121-
IntegrationKind.GITHUB,
122-
IntegrationState.ACTIVE
123-
)
99+
.findActive(workspaceId, IntegrationKind.GITHUB)
124100
.map(Connection::getConfig)
125101
.filter(c -> c instanceof ConnectionConfig.GitHubAppConfig)
126102
.map(c -> (ConnectionConfig.GitHubAppConfig) c);
@@ -129,11 +105,7 @@ public Optional<ConnectionConfig.GitHubAppConfig> findActiveGitHubAppConfig(long
129105
@Transactional(readOnly = true)
130106
public Optional<ConnectionConfig.GitHubPatConfig> findActiveGitHubPatConfig(long workspaceId) {
131107
return connectionRepository
132-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
133-
workspaceId,
134-
IntegrationKind.GITHUB,
135-
IntegrationState.ACTIVE
136-
)
108+
.findActive(workspaceId, IntegrationKind.GITHUB)
137109
.map(Connection::getConfig)
138110
.filter(c -> c instanceof ConnectionConfig.GitHubPatConfig)
139111
.map(c -> (ConnectionConfig.GitHubPatConfig) c);
@@ -142,11 +114,7 @@ public Optional<ConnectionConfig.GitHubPatConfig> findActiveGitHubPatConfig(long
142114
@Transactional(readOnly = true)
143115
public Optional<ConnectionConfig.GitLabConfig> findActiveGitLabConfig(long workspaceId) {
144116
return connectionRepository
145-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
146-
workspaceId,
147-
IntegrationKind.GITLAB,
148-
IntegrationState.ACTIVE
149-
)
117+
.findActive(workspaceId, IntegrationKind.GITLAB)
150118
.map(Connection::getConfig)
151119
.filter(c -> c instanceof ConnectionConfig.GitLabConfig)
152120
.map(c -> (ConnectionConfig.GitLabConfig) c);
@@ -155,11 +123,7 @@ public Optional<ConnectionConfig.GitLabConfig> findActiveGitLabConfig(long works
155123
@Transactional(readOnly = true)
156124
public Optional<ConnectionConfig.SlackConfig> findSlackNotificationConfig(long workspaceId) {
157125
return connectionRepository
158-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
159-
workspaceId,
160-
IntegrationKind.SLACK,
161-
IntegrationState.ACTIVE
162-
)
126+
.findActive(workspaceId, IntegrationKind.SLACK)
163127
.map(Connection::getConfig)
164128
.filter(c -> c instanceof ConnectionConfig.SlackConfig)
165129
.map(c -> (ConnectionConfig.SlackConfig) c);
@@ -168,11 +132,7 @@ public Optional<ConnectionConfig.SlackConfig> findSlackNotificationConfig(long w
168132
@Transactional(readOnly = true)
169133
public Optional<ConnectionConfig.OutlineConfig> findActiveOutlineConfig(long workspaceId) {
170134
return connectionRepository
171-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
172-
workspaceId,
173-
IntegrationKind.OUTLINE,
174-
IntegrationState.ACTIVE
175-
)
135+
.findActive(workspaceId, IntegrationKind.OUTLINE)
176136
.map(Connection::getConfig)
177137
.filter(c -> c instanceof ConnectionConfig.OutlineConfig)
178138
.map(c -> (ConnectionConfig.OutlineConfig) c);
@@ -236,7 +196,7 @@ public record OutlineSubscription(long workspaceId, String serverUrl, String sig
236196
@Transactional(readOnly = true)
237197
public Optional<BearerToken> findActiveBearerToken(long workspaceId, IntegrationKind kind) {
238198
return connectionRepository
239-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(workspaceId, kind, IntegrationState.ACTIVE)
199+
.findActive(workspaceId, kind)
240200
.flatMap(c -> c.credentials(credentialConverter))
241201
.flatMap(b -> b instanceof BearerToken bt ? Optional.of(bt) : Optional.empty());
242202
}
@@ -266,11 +226,7 @@ public Optional<Connection> findReferenced(IntegrationRef ref) {
266226
ref.instanceKey()
267227
);
268228
}
269-
return connectionRepository.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(
270-
ref.workspaceId(),
271-
ref.kind(),
272-
IntegrationState.ACTIVE
273-
);
229+
return connectionRepository.findActive(ref.workspaceId(), ref.kind());
274230
}
275231

276232
@Transactional(readOnly = true)
@@ -310,7 +266,7 @@ public Optional<Connection> updateConfig(
310266
UnaryOperator<ConnectionConfig> mutator
311267
) {
312268
return connectionRepository
313-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(workspaceId, kind, IntegrationState.ACTIVE)
269+
.findActive(workspaceId, kind)
314270
.map(c -> {
315271
ConnectionConfig next = mutator.apply(c.getConfig());
316272
if (next == null) {
@@ -340,7 +296,7 @@ public Optional<Connection> updateConfig(
340296
@Transactional
341297
public Optional<Connection> rotateBearerToken(long workspaceId, IntegrationKind kind, BearerToken bundle) {
342298
return connectionRepository
343-
.findFirstByWorkspaceIdAndKindAndStateOrderByCreatedAtDesc(workspaceId, kind, IntegrationState.ACTIVE)
299+
.findActive(workspaceId, kind)
344300
.map(c -> {
345301
c.setCredentials(bundle, credentialConverter);
346302
return connectionRepository.save(c);
@@ -656,38 +612,8 @@ private Connection applyTransition(
656612
}
657613

658614
/**
659-
* Runs the vendor revoke / provider-data erase in its own {@code REQUIRES_NEW} transaction and
660-
* absorbs its failure, so "best effort, proceed locally" is actually reachable.
661-
*
662-
* <p><b>Why a nested transaction and not a plain try/catch.</b> The erasers
663-
* ({@code ScmWorkspaceContentEraser}, {@code SlackWorkspaceContentEraser}, the Outline
664-
* {@code deleteByWorkspaceId} sweep) are {@code @Transactional} with default {@code REQUIRED}
665-
* propagation, so joining the lifecycle transaction would let a {@code DataAccessException} from any
666-
* of them — an FK surprise, a statement timeout on a large mirror delete — mark the shared
667-
* transaction rollback-only, and the commit would then fail with {@code UnexpectedRollbackException}
668-
* even though the transition and audit row were written. Running the callback on its own transaction
669-
* confines that poisoning to the callback; catching OUTSIDE the template also absorbs the
670-
* {@code UnexpectedRollbackException} raised at the nested commit when a callback swallowed the
671-
* failure internally.
672-
*
673-
* <p><b>Why this cannot deadlock against our own row lock.</b> We hold {@code SELECT … FOR UPDATE}
674-
* on the {@code connection} row on the outer connection, and the nested transaction runs on a
675-
* second pooled connection — so it must never wait on that row. It does not: no revoke path writes
676-
* the {@code connection} row (both {@code OutlineWebhookRegistrar#deregister} and
677-
* {@code GitLabWebhookService#deregisterActiveWebhook} refuse to rewrite config there), and the FK
678-
* children they DO write are only ever deleted — {@code outline_document},
679-
* {@code outline_collection} and {@code outline_document_event} carry a {@code connection_id}, but
680-
* PostgreSQL runs no parent-side referential check on a child DELETE, so no {@code FOR KEY SHARE} is
681-
* taken on the locked row. Plain reads of {@code connection} from the nested transaction (e.g.
682-
* {@code findActiveBearerToken}) are MVCC snapshots and never block on {@code FOR UPDATE}. Adding a
683-
* write of {@code connection} — or an INSERT into any table keyed on it — to a revoke path would
684-
* break this and self-deadlock.
685-
*
686-
* <p><b>Accepted consequence.</b> Erase and transition are not atomic. A revoke that erases
687-
* successfully still commits even if the transition afterwards fails (duplicate-correlation
688-
* short-circuit, illegal transition), leaving erased data behind an ACTIVE connection. Every erase
689-
* path is idempotent and the next disconnect re-runs it, so this is recoverable — and it is the
690-
* direction to fail in, since the reverse strands the admin with a 500 and an ACTIVE connection.
615+
* Isolates vendor revocation from the local lifecycle transition. A vendor failure rolls back its
616+
* database work while the local transition proceeds, so the operation is intentionally non-atomic.
691617
*/
692618
private void runRevokeIsolated(Connection connection, Runnable revoke) {
693619
try {

server/src/main/java/de/tum/cit/aet/hephaestus/integration/core/connection/api/InitiateConnectionRequestDTO.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,5 @@
1010
* <p>{@code userInput} is intentionally a free-form map so per-kind ConnectionStrategy
1111
* implementations can dictate their own field schema (e.g. GitLab needs {@code pat} +
1212
* {@code group_id}; GitHub needs nothing because the install URL is server-configured).
13-
* Provider-specific input validation is the strategy's responsibility.
1413
*/
1514
public record InitiateConnectionRequestDTO(@NotNull IntegrationKind kind, Map<String, String> userInput) {}

server/src/main/java/de/tum/cit/aet/hephaestus/integration/outline/lifecycle/OutlineWebhookRegistrar.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ public void deregister(long workspaceId) {
256256
public void deregister(long workspaceId, long connectionId) {
257257
boolean hadSubscription;
258258
try {
259-
hadSubscription = deregisterStrictWithoutTransaction(workspaceId, connectionId);
259+
hadSubscription = deregisterStrictInternal(workspaceId, connectionId);
260260
} catch (RuntimeException e) {
261261
log.warn("outline.webhook: deregistration failed for connectionId={}: {}", connectionId, e.toString());
262262
hadSubscription = true;
@@ -268,10 +268,10 @@ public void deregister(long workspaceId, long connectionId) {
268268

269269
@Transactional(propagation = Propagation.NOT_SUPPORTED)
270270
public boolean deregisterStrict(long workspaceId, long connectionId) {
271-
return deregisterStrictWithoutTransaction(workspaceId, connectionId);
271+
return deregisterStrictInternal(workspaceId, connectionId);
272272
}
273273

274-
private boolean deregisterStrictWithoutTransaction(long workspaceId, long connectionId) {
274+
private boolean deregisterStrictInternal(long workspaceId, long connectionId) {
275275
Optional<Connection> connection = connectionService.findInWorkspace(workspaceId, connectionId);
276276
if (connection.isEmpty() || !(connection.get().getConfig() instanceof ConnectionConfig.OutlineConfig config)) {
277277
return false;

server/src/main/java/de/tum/cit/aet/hephaestus/integration/scm/github/pullrequestreview/GitHubPullRequestReviewSyncService.java

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,8 @@
6060
* Uses typed GraphQL models for type-safe deserialization and delegates
6161
* persistence to GitHubPullRequestReviewProcessor.
6262
* <p>
63-
* GraphQL fetching is non-transactional; persistence is done per-page in
64-
* {@code REQUIRES_NEW} transactions via self-proxy to isolate deadlock
65-
* failures and avoid poisoned-transaction retries.
63+
* Reviews are fetched outside a transaction and persisted one page at a time so deadlock retries
64+
* start clean.
6665
*/
6766
@Service
6867
public class GitHubPullRequestReviewSyncService {
@@ -417,11 +416,7 @@ public int syncRemainingReviews(Long scopeId, PullRequest pullRequest, String st
417416
return totalSynced;
418417
}
419418

420-
/**
421-
* Persists a page of reviews with transient failure retry. Each attempt runs in a fresh
422-
* {@code REQUIRES_NEW} transaction via self-proxy, so a deadlock on one attempt
423-
* does not poison subsequent retries.
424-
*/
419+
/** Persists a page of reviews in a new transaction and retries transient failures. */
425420
private int persistReviewPageWithRetry(
426421
List<GHPullRequestReview> reviews,
427422
Long pullRequestId,
@@ -479,19 +474,6 @@ private int persistReviewPageWithRetry(
479474
return 0;
480475
}
481476

482-
/**
483-
* Processes a page of review nodes in a {@code REQUIRES_NEW} transaction.
484-
* <p>
485-
* Called via self-proxy to ensure the transaction annotation is honoured.
486-
* If a deadlock occurs, the transaction is rolled back independently without
487-
* poisoning any outer transaction.
488-
*
489-
* @param reviews the review nodes from the GraphQL response
490-
* @param pullRequestId the database ID of the owning pull request
491-
* @param scopeId the scope ID for authentication
492-
* @param repository the repository entity for creating the processing context
493-
* @return number of reviews persisted
494-
*/
495477
private int processReviewPage(
496478
List<GHPullRequestReview> reviews,
497479
Long pullRequestId,

server/src/main/java/de/tum/cit/aet/hephaestus/integration/scm/gitlab/issue/GitLabIssueProcessor.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,15 @@ public boolean purgeConfidential(GitLabIssueEventDTO event, ProcessingContext co
174174
}
175175

176176
/**
177-
* Handles an {@code action=update} issue event. Persists the issue via {@link #process} (which
178-
* overwrites the label set to the new state), then emits one {@link ScmDomainEvent.IssueLabeled}
177+
* Handles an {@code action=update} issue event. Persists the issue, then emits one
178+
* {@link ScmDomainEvent.IssueLabeled}
179179
* per newly-added label — GitLab has no native "labeled" action, so this is how the IssueLabeled
180180
* trigger reaches parity with GitHub. The added-label delta is read from the webhook's
181181
* {@code changes.labels} diff, so a plain title/description edit emits nothing.
182-
*
183-
* <p>{@code @Transactional} so the whole update runs in one transaction: the self-invoked
184-
* {@link #process} bypasses its own proxy (Spring AOP self-invocation), so without this its writes
185-
* would run with no active transaction.
186182
*/
187183
@Transactional
188184
@Nullable
189185
public Issue processUpdated(GitLabIssueEventDTO event, ProcessingContext context) {
190-
// Capture the delta from the payload (independent of the entity's label set, which process() rewrites).
191186
List<GitLabWebhookLabel> addedLabels = event.addedLabels();
192187
Issue issue = processInternal(event, context);
193188
if (issue == null || addedLabels.isEmpty()) {

0 commit comments

Comments
 (0)