Summary
Several write paths in this service issue multiple dependent SQL statements that are not
atomic. A failure partway through leaves the database in a state that no single request can
produce deliberately, and no rollback occurs.
This was observed while reviewing PR #193 and is filed for visibility rather than as a
regression from that change — the gap predates it.
Where this occurs
EntityRepositoryImpl.deleteById (src/main/java/preponderous/viron/repositories/EntityRepositoryImpl.java:97-98)
clears the entity's viron.entity_location row and then deletes the entity. If the second
statement fails, the entity survives but has silently lost its placement.
EnvironmentController.deleteEnvironment (src/main/java/preponderous/viron/controllers/EnvironmentController.java:87-125)
performs a much longer cascade — association rows, then entities, then locations, then
grids — throwing ServiceException from the middle of the loop on the first failure.
Every deletion already performed is retained, leaving a partially deleted environment.
LocationController.addEntityToLocation (src/main/java/preponderous/viron/controllers/LocationController.java:78-94)
reads the location, the entity and the current placement before writing. Two concurrent
requests for the same unplaced entity can both observe no placement and both attempt the
insert. The composite primary key on viron.entity_location still enforces the invariant,
so no corruption results, but the loser receives a 500 rather than the 409 the guard is
meant to produce.
Why this is not a one-line fix
@Transactional is not used anywhere in the codebase, and no transaction manager is
configured. Adding one is complicated by how DbInteractions is built
(src/main/java/preponderous/viron/database/DbInteractions.java:26-33): a single
java.sql.Connection is opened in the constructor and held as state on an application-scoped
@Component, and setAutoCommit(false) is never called.
That single shared connection means transaction boundaries cannot simply be switched on.
Calling setAutoCommit(false) on a connection shared by every concurrent request would
enclose unrelated requests in the same transaction, so one request's rollback would discard
another's committed-looking work. The shared mutable connection is also a thread-safety
concern in its own right, since a JDBC Connection is not required to be thread-safe.
Proposed work
The prerequisite should be addressed first, then the boundaries:
DbInteractions should obtain connections from a pooled DataSource (Spring Boot
auto-configures HikariCP when spring-boot-starter-jdbc is present) rather than holding
one long-lived connection, so each request gets its own.
- A transaction manager should be configured and the multi-statement paths above annotated
@Transactional, so a partial cascade rolls back.
- Once the placement insert and its guard run in one transaction, the race in
addEntityToLocation can be closed and the duplicate-placement loser can be reported as a
409 rather than a 500.
Steps 1 and 2 are worth splitting into separate changes if the connection-management rework
proves large.
Notes
No data corruption is reachable through the API today — the schema constraints hold in every
case examined. The impact is partial deletes that require manual cleanup, and a misleading
500 on a narrow concurrent path.
This issue body was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).
Summary
Several write paths in this service issue multiple dependent SQL statements that are not
atomic. A failure partway through leaves the database in a state that no single request can
produce deliberately, and no rollback occurs.
This was observed while reviewing PR #193 and is filed for visibility rather than as a
regression from that change — the gap predates it.
Where this occurs
EntityRepositoryImpl.deleteById(src/main/java/preponderous/viron/repositories/EntityRepositoryImpl.java:97-98)clears the entity's
viron.entity_locationrow and then deletes the entity. If the secondstatement fails, the entity survives but has silently lost its placement.
EnvironmentController.deleteEnvironment(src/main/java/preponderous/viron/controllers/EnvironmentController.java:87-125)performs a much longer cascade — association rows, then entities, then locations, then
grids — throwing
ServiceExceptionfrom the middle of the loop on the first failure.Every deletion already performed is retained, leaving a partially deleted environment.
LocationController.addEntityToLocation(src/main/java/preponderous/viron/controllers/LocationController.java:78-94)reads the location, the entity and the current placement before writing. Two concurrent
requests for the same unplaced entity can both observe no placement and both attempt the
insert. The composite primary key on
viron.entity_locationstill enforces the invariant,so no corruption results, but the loser receives a 500 rather than the 409 the guard is
meant to produce.
Why this is not a one-line fix
@Transactionalis not used anywhere in the codebase, and no transaction manager isconfigured. Adding one is complicated by how
DbInteractionsis built(
src/main/java/preponderous/viron/database/DbInteractions.java:26-33): a singlejava.sql.Connectionis opened in the constructor and held as state on an application-scoped@Component, andsetAutoCommit(false)is never called.That single shared connection means transaction boundaries cannot simply be switched on.
Calling
setAutoCommit(false)on a connection shared by every concurrent request wouldenclose unrelated requests in the same transaction, so one request's rollback would discard
another's committed-looking work. The shared mutable connection is also a thread-safety
concern in its own right, since a JDBC
Connectionis not required to be thread-safe.Proposed work
The prerequisite should be addressed first, then the boundaries:
DbInteractionsshould obtain connections from a pooledDataSource(Spring Bootauto-configures HikariCP when
spring-boot-starter-jdbcis present) rather than holdingone long-lived connection, so each request gets its own.
@Transactional, so a partial cascade rolls back.addEntityToLocationcan be closed and the duplicate-placement loser can be reported as a409 rather than a 500.
Steps 1 and 2 are worth splitting into separate changes if the connection-management rework
proves large.
Notes
No data corruption is reachable through the API today — the schema constraints hold in every
case examined. The impact is partial deletes that require manual cleanup, and a misleading
500 on a narrow concurrent path.
This issue body was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).