fix: make the multi-statement write paths atomic - #199
Merged
Conversation
The environment cascade delete, the entity delete and environment creation each issue a long sequence of dependent statements with no transaction around them. A failure partway through left rows behind that no request could produce deliberately: a half-deleted environment, an entity that survived while its placement was already cleared, or an environment missing some of its grids. Now that connections come from a pool rather than one shared Connection, boundaries can be placed. A DataSourceTransactionManager is declared next to the pool it wraps, and the three paths are annotated @transactional; DbInteractions already borrows through DataSourceUtils, so its calls join the surrounding transaction and the runtime exception each path throws on the first failure discards the whole sequence. The transaction manager is declared rather than left to auto-configuration, which backs off as soon as a second DataSource candidate or another manager appears. TransactionRollbackTest proves the rollback against H2 through the production wiring, with a control case showing the unbounded behaviour it replaces. TransactionBoundaryWiringTest asserts each annotated method is actually advised, since @transactional is silent when it is not. The two controller tests stop mocking DbConfig: the boundary opens a real connection before the mocked repository call, and a mocked DbConfig gave the pool a null JDBC URL. The test datasource points at H2 for the same reason. Part of #194. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EnvironmentControllerTest mocks EnvironmentFactory, so the creation boundary is never reached there — only deleteEnvironment opens one. The test properties header also still claimed to mirror the runtime configuration after the datasource stopped doing so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
Self-review rubricScored against the diff and command output, not judgement. Two items failed on the first pass and were fixed in fdef53a; both are recorded below as found rather than silently corrected.
Repo-specific
Findings folded in from the diff read
This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener). drafted by Claude on behalf of Daniel Stephenson |
This was referenced Aug 10, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
DataSourceTransactionManageris declared next to the pool it wraps inDataSourceConfig, so the write paths have a transaction to join. It is declared rather than left to Spring Boot's auto-configuration, which backs off as soon as a secondDataSourcecandidate or another manager appears.@Transactional, so the runtime exception each already throws on the first failure now discards the whole sequence instead of leaving rows behind that no request could produce deliberately:EnvironmentController.deleteEnvironment— the association/entity/location/grid cascade, previously abandoned partway through on the first failure.EntityController.deleteEntity—EntityRepositoryImpl.deleteByIdclears the placement before deleting the entity, so a failed entity delete previously left the entity without its placement.EnvironmentFactory.createEnvironment— the environment, its grids, its locations and every association between them, previously able to leave an environment missing grids or a grid missing locations.DbInteractionsis unchanged: it already borrows throughDataSourceUtils, so its calls join the surrounding transaction. That was the reason connection pooling landed first in refactor: pool database connections, and fix the blocked local-run configuration #198.DbConfig, and the test datasource points at H2. The boundary opens a real connection before the mocked repository call, and a mockedDbConfighanded the pool a null JDBC URL — those requests failed with a 500 rather than exercising the controller.What is deliberately not included
LocationController.addEntityToLocationas a 409 rather than a 500 — is left out. A boundary alone does not close that race under READ COMMITTED, and distinguishing a unique-key violation from any other failure requiresDbInteractions.updateto surface theSQLExceptionit currently swallows, which changes error semantics for every repository. A follow-up issue is filed for it, and Multi-statement writes are not atomic; a shared JDBC connection blocks adding transactions #194 is therefore referenced rather than closed here.pom.xmland.github/workflows, which is not coherent with this batch.Notes on the sequences
Postgres sequences are not transactional, so a rolled-back creation still consumes the ids it drew. The resulting gap is expected and harmless; it is documented on the factory method.
Test plan
mvn -B test— 413 tests, all passing.TransactionRollbackTestexercises the realEntityRepositoryImpl.deleteByIdagainst H2 through the productionDataSourceConfigwiring, with a foreign key pointed at the entity so the second statement fails. The placement is restored inside a boundary; a control case in the same class shows it lost without one; a third case confirms the successful path still commits both statements.TransactionBoundaryWiringTestasserts each annotated method is genuinely advised —@Transactionalis silent when it is not — and that exactly one transaction manager is bound to the pooledDataSource.@Transactionalannotations reverted,TransactionBoundaryWiringTestfails 3 of its 4 cases; with them restored it passes.pytest— 107 tests, all passing. The Python client is untouched by this change, and CI does not cover it. (Collection requires the repository root onsys.path;--import-mode=importlibwas used, a pre-existing environment quirk.)docs/openapi/viron-api.json, the Postman collection and the planning docs need no update; none of them documents persistence or transaction behaviour.Part of #194.
This PR description was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).
drafted by Claude on behalf of Daniel Stephenson