Skip to content

Multi-statement writes are not atomic; a shared JDBC connection blocks adding transactions #194

Description

@dmccoystephenson

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:

  1. 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.
  2. A transaction manager should be configured and the multi-statement paths above annotated
    @Transactional, so a partial cascade rolls back.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions