From 694bf3fa863c9539b0a19c84534b98b528daeddd Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Wed, 4 Nov 2026 17:10:25 +0100 Subject: [PATCH 1/2] S16 postgresql opslag added --- .gitea/workflows/deploy.yaml | 4 +- .gitea/workflows/rollback.yaml | 2 +- Dockerfile | 7 +- README.md | 43 +- agiledancers-be/pom.xml | 15 + .../agiledancers/model/DanceSchool.java | 3 + .../scheduling}/SchedulingRun.java | 9 +- .../scheduling}/SchedulingState.java | 3 +- .../repository/AbstractRepository.java | 10 +- .../agiledancers/repository/Database.java | 105 +++++ .../agiledancers/repository/GroupMapping.java | 62 +++ .../repository/PeopleMapping.java | 70 ++++ .../repository/RevisionConflictException.java | 8 + .../repository/RunRepository.java | 78 ++++ .../agiledancers/repository/SchoolReader.java | 47 +++ .../agiledancers/repository/SchoolWriter.java | 82 ++++ .../agiledancers/repository/ShowMapping.java | 59 +++ .../repository/StorageException.java | 8 + .../repository/entity/CoachEntity.java | 33 ++ .../repository/entity/DancerEntity.java | 20 + .../repository/entity/DayPartEntity.java | 56 +++ .../repository/entity/DayPartId.java | 26 ++ .../repository/entity/EntityIdentifiers.java | 24 ++ .../repository/entity/GroupEntity.java | 72 ++++ .../repository/entity/RunEntity.java | 59 +++ .../repository/entity/SchoolEntity.java | 52 +++ .../repository/entity/SchoolMemberEntity.java | 21 + .../repository/entity/SchoolMemberId.java | 23 ++ .../repository/entity/ShowEntity.java | 51 +++ .../agiledancers/rest/CoachResource.java | 10 +- .../agiledancers/rest/DanceGroupResource.java | 12 +- .../rest/DanceSchoolResource.java | 29 +- .../agiledancers/rest/DancerResource.java | 12 +- .../rest/ManagementExceptionMapper.java | 1 + .../rest/SchedulingEventsResource.java | 7 +- .../rest/SchoolRequestInterceptor.java | 50 +++ .../rest/SchoolSnapshotResource.java | 6 +- .../rest/SchoolTransactionBoundary.java | 13 + .../agiledancers/rest/ShowResource.java | 8 +- .../agiledancers/rest/apimodel/CoachView.java | 11 +- .../rest/apimodel/DancerView.java | 7 +- .../agiledancers/rest/apimodel/GroupView.java | 13 - .../rest/apimodel/SchedulingEventView.java | 2 +- .../rest/apimodel/SchoolDataView.java | 18 + .../rest/apimodel/SchoolView.java | 16 +- .../agiledancers/rest/apimodel/ShowView.java | 25 -- .../rest/mapping/DomainViewMapper.java | 59 +++ .../service/ManagementExceptionKind.java | 1 + .../service/SchedulingPersistence.java | 54 +++ .../agiledancers/service/SchedulingRuns.java | 175 +++++--- .../service/SchoolManagementService.java | 33 +- .../service/SchoolTransactions.java | 118 ++++++ .../service/model/ImportPreview.java | 3 +- .../service/model/SchoolChange.java | 4 + .../src/main/resources/application.properties | 16 +- .../db/migration/V1__persistent_schools.sql | 71 ++++ .../V2__jpa_aggregate_versioning.sql | 15 + .../agiledancers/DomainViewMapperTest.java | 119 ++++++ .../agilecc/agiledancers/PersistenceTest.java | 386 ++++++++++++++++++ .../agiledancers/RevisionRequests.java | 50 +++ .../agilecc/agiledancers/S18ResourceTest.java | 2 +- .../agiledancers/SchedulingEventsTest.java | 10 +- .../SchoolManagementResourceTest.java | 2 +- .../SchoolSnapshotResourceTest.java | 2 +- .../service/SchedulingRunsTest.java | 4 +- agiledancers-fe/e2e/school-management.spec.ts | 64 ++- agiledancers-fe/src/app/core/api-models.ts | 1 + .../src/app/core/scheduling-status.ts | 1 + .../src/app/core/school-api.spec.ts | 40 +- agiledancers-fe/src/app/core/school-api.ts | 116 ++++-- .../src/app/schools/school-page.html | 3 + .../src/app/schools/school-page.ts | 7 +- .../src/app/shared/entity-editor.spec.ts | 25 +- .../src/app/shared/entity-editor.ts | 8 +- .../src/app/shared/group-members.ts | 8 +- .../src/app/shared/schedule-editor.spec.ts | 2 +- .../src/app/shared/schedule-editor.ts | 13 +- .../src/app/shared/school-transfer.ts | 8 +- ci/Pipeline.java | 34 +- deploy/compose.yaml | 25 ++ deploy/production.env.example | 3 + deploy/remote.sh | 6 +- docs/persistent-storage.md | 135 ++++++ docs/production-deployment.md | 20 +- pom.xml | 2 + specs/S16-validation.md | 156 +++++++ .../S12_support-async-feedback-for-scheduling | 4 +- .../S16_store-state-in-persistent-storage | 33 +- tooling/persistence/restart_test.py | 209 ++++++++++ 89 files changed, 3087 insertions(+), 252 deletions(-) rename agiledancers-be/src/main/java/nl/agilecc/agiledancers/{service/model => model/scheduling}/SchedulingRun.java (85%) rename agiledancers-be/src/main/java/nl/agilecc/agiledancers/{service/model => model/scheduling}/SchedulingState.java (69%) create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolTransactions.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolChange.java create mode 100644 agiledancers-be/src/main/resources/db/migration/V1__persistent_schools.sql create mode 100644 agiledancers-be/src/main/resources/db/migration/V2__jpa_aggregate_versioning.sql create mode 100644 agiledancers-be/src/test/java/nl/agilecc/agiledancers/DomainViewMapperTest.java create mode 100644 agiledancers-be/src/test/java/nl/agilecc/agiledancers/PersistenceTest.java create mode 100644 agiledancers-be/src/test/java/nl/agilecc/agiledancers/RevisionRequests.java create mode 100644 docs/persistent-storage.md create mode 100644 specs/S16-validation.md rename specs/{in_review => implemented}/S12_support-async-feedback-for-scheduling (98%) rename specs/{refined => in_review}/S16_store-state-in-persistent-storage (85%) create mode 100644 tooling/persistence/restart_test.py diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 1c167c5..b0418cd 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -3,7 +3,7 @@ on: workflow_dispatch: inputs: mode: - description: 'Release mode — deployment can erase data until S16 is implemented' + description: 'Release mode — export legacy data before the first S16 deployment' required: true type: choice default: new @@ -44,7 +44,7 @@ jobs: persist-credentials: false - name: Identify workflow attempt run: java ci/Ci.java attempt - - name: Release verified images — data can be lost until S16 + - name: Release verified images — persistent PostgreSQL storage env: RELEASE_MODE: ${{ inputs.mode }} RELEASE_INCREMENT: ${{ inputs.increment }} diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml index 1f3721d..feff460 100644 --- a/.gitea/workflows/rollback.yaml +++ b/.gitea/workflows/rollback.yaml @@ -21,7 +21,7 @@ jobs: persist-credentials: false - name: Identify workflow attempt run: java ci/Ci.java attempt - - name: Restore previous successful release — data can be lost until S16 + - name: Restore previous successful release — requires a compatible database schema env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} diff --git a/Dockerfile b/Dockerfile index 18de3e0..fe44e43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,12 @@ COPY tooling tooling COPY ci ci COPY agiledancers-be/pom.xml agiledancers-be/pom.xml COPY agiledancers-be/src agiledancers-be/src -RUN mvn -B -ntp clean verify +# Source builds run the same PostgreSQL tests as local/CI verification. +# Supply connection configuration as BuildKit secrets, which are not retained in image layers. +RUN --mount=type=secret,id=database_url,env=QUARKUS_DATASOURCE_JDBC_URL,required=true \ + --mount=type=secret,id=database_user,env=QUARKUS_DATASOURCE_USERNAME,required=true \ + --mount=type=secret,id=database_password,env=QUARKUS_DATASOURCE_PASSWORD,required=true \ + mvn -B -ntp clean verify FROM eclipse-temurin:25-jre AS runtime WORKDIR /app diff --git a/README.md b/README.md index 8a43ab6..ecbebc5 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,19 @@ mvn -pl agiledancers-be quarkus:dev Run the packaged application after building: ```sh +# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first. java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar ``` -Build and run a Docker container from the repository root (only Docker is required): +Build a Docker container against an isolated test database (see [connection setup](docs/persistent-storage.md)): ```sh -docker build -t agiledancers . -docker run --rm --name agiledancers -p 8080:8080 agiledancers +docker build --network=host \ + --secret id=database_url,env=TEST_DATABASE_URL \ + --secret id=database_user,env=TEST_DATABASE_USER \ + --secret id=database_password,env=TEST_DATABASE_PASSWORD -t agiledancers . +docker run --rm --name agiledancers -p 8080:8080 \ + -e DATABASE_URL -e DATABASE_USER -e DATABASE_PASSWORD agiledancers ``` The Docker build compiles the application and runs the tests in a Maven build stage, @@ -42,7 +47,13 @@ curl --fail http://localhost:8080/q/health/ready Quarkus configuration can be overridden with environment variables, for example `docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`. -The REST API uses shared in-memory storage; data is lost when the process stops. +PostgreSQL stores all school data, planning state and the latest scheduling run +through JPA/Hibernate ORM. Persistence entities remain in the repository layer. +Flyway initializes and versions the schema. Production requires `DATABASE_URL` +(a JDBC PostgreSQL URL), `DATABASE_USER` and `DATABASE_PASSWORD`; missing credentials, +connection failures and migration failures prevent startup. See +[database setup and transition](docs/persistent-storage.md) before replacing an +in-memory release: export every school before stopping it. Architecture checks run with the normal test suite, or separately with: @@ -56,7 +67,7 @@ mvn -pl agiledancers-be -Dtest=ArchitectureTest test | --- | --- | --- | | `model` | Domain entities and specifications | Model | | `service` | Application use cases and future ports | Model, repositories | -| `repository` | Current in-memory persistence | Model | +| `repository` | JPA/Hibernate entities, repositories and transaction boundaries | Model | | `rest` | Future REST services | Services, model | | `solver` | Future OR-Tools calculations | Services (ports), model | @@ -175,8 +186,9 @@ recover current state on reconnection rather than requiring event replay. The production proxy disables buffering. The UI shows phase, elapsed time, solution discovery and final objective scores, and reports completion in an application-wide snackbar. Reloading recovers the latest state without replaying old notifications. -Run state currently shares the application's in-memory lifetime; durable restart -recovery requires the PostgreSQL integration specified in S16. +The latest run and saved assignments survive restart. Startup marks formerly +running calculations `INTERRUPTED`, preserves the previous assignments, and allows +a new run. Results and successful status commit in one database transaction. `PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved; @@ -281,7 +293,7 @@ for the current operational status. Production releases and rollback use separate manual Gitea workflows. See the [deployment runbook](docs/production-deployment.md) for Synology provisioning, semantic version tags, release/retry/rollback and the accepted data-loss limitation -until S16, and [S6 evidence](docs/s6-validation.md) for deployment readiness. +during the one-time transition from an in-memory release, and [S6 evidence](docs/s6-validation.md) for deployment readiness. ### Java formatting (S8) @@ -346,3 +358,18 @@ analysis tools, coverage policy and standalone commands. `mvn verify` runs stand checks and requires at least 80% module-wide backend line coverage. CI also collects and merges coverage from Playwright before publication. Reports are retained with CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status. + + +Existing-school mutations require `X-School-Revision` with the revision loaded by +the editor. `GET /api/dance-schools/{id}/data` returns the school and all its editor +data from one consistent snapshot; `school.revision` identifies that snapshot. +Missing or stale revisions receive `409` with a reload instruction. The import +preview returns `existingRevision` for replacement confirmation. Creation of a +new school does not need a revision. The frontend never automatically retries a +conflicting edit with a newer revision. + + +Domain-to-view projection is generated by MapStruct in `rest.mapping.DomainViewMapper`. +It is a CDI bean with `unmappedTargetPolicy = ERROR`, so unmatched target fields fail +compilation. REST resources supply calculated validation results explicitly. The +entity-to-domain graph mappings remain handwritten in the repository layer. diff --git a/agiledancers-be/pom.xml b/agiledancers-be/pom.xml index 6d850c0..5f45e1f 100644 --- a/agiledancers-be/pom.xml +++ b/agiledancers-be/pom.xml @@ -14,6 +14,11 @@ jar + org.mapstructmapstruct${mapstruct.version} + io.quarkusquarkus-hibernate-orm + io.quarkusquarkus-jdbc-postgresql + io.quarkusquarkus-flyway + org.flywaydbflyway-database-postgresql io.quarkus quarkus-rest-jackson @@ -63,6 +68,16 @@ + + org.apache.maven.pluginsmaven-compiler-plugin + + + org.mapstructmapstruct-processor${mapstruct.version} + org.projectlomboklombok-mapstruct-binding${lombok-mapstruct-binding.version} + + -Amapstruct.suppressGeneratorTimestamp=true + + org.apache.maven.pluginsmaven-dependency-plugin3.8.1 standards-classpathprepare-packagebuild-classpath diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java index d2d3ca1..fe9b448 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java @@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity { @Setter private String name; + @Setter + private long revision; + @Builder.Default private final Set dancers = new LinkedHashSet<>(); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java similarity index 85% rename from agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java rename to agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java index 2570019..16084d8 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java @@ -1,10 +1,7 @@ -package nl.agilecc.agiledancers.service.model; +package nl.agilecc.agiledancers.model.scheduling; import java.time.Instant; import java.util.UUID; -import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase; -import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; -import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; /** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */ public record SchedulingRun( @@ -24,7 +21,7 @@ public record SchedulingRun( schoolId, showId, name, - Instant.now(), + Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS), null, SchedulingState.RUNNING, new SchedulingProgress(SchedulingPhase.PREPARING, false), @@ -54,7 +51,7 @@ public record SchedulingRun( showId, showName, startedAt, - Instant.now(), + Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS), outcome, new SchedulingProgress(progress.phase(), progress.feasible() || scores != null), explanation, diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java similarity index 69% rename from agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java rename to agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java index f0ae7c4..4df1e0c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java @@ -1,7 +1,8 @@ -package nl.agilecc.agiledancers.service.model; +package nl.agilecc.agiledancers.model.scheduling; public enum SchedulingState { RUNNING, + INTERRUPTED, OPTIMAL, VALID, STALE, diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java index 5da8a3c..6180885 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java @@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity; public abstract class AbstractRepository { private long sequence = 0L; + private java.util.function.LongSupplier identifiers; protected final Map entities = new LinkedHashMap<>(); public long nextId() { - return sequence++; + return identifiers == null ? sequence++ : identifiers.getAsLong(); + } + + /** Replaces detached transaction state; production identifiers come from PostgreSQL. */ + public void load(final java.util.Collection values, final java.util.function.LongSupplier identifiers) { + entities.clear(); + values.forEach((final T entity) -> entities.put(entity.getId(), entity)); + this.identifiers = identifiers; } public T add(final T entity) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java new file mode 100644 index 0000000..8ce8149 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java @@ -0,0 +1,105 @@ +package nl.agilecc.agiledancers.repository; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.OptimisticLockException; +import jakarta.persistence.PersistenceException; +import jakarta.transaction.Status; +import jakarta.transaction.TransactionManager; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import nl.agilecc.agiledancers.repository.entity.EntityIdentifiers; + +/** Owns short JTA units of work; Hibernate flushes before commit and publication follows durable commit. */ +@Singleton +public class Database { + private final EntityManager entities; + private final TransactionManager manager; + private final ThreadLocal> committed = new ThreadLocal<>(); + private final ThreadLocal> rolledBack = new ThreadLocal<>(); + + @Inject + public Database(final EntityManager entities, final TransactionManager manager) { + this.entities = entities; + this.manager = manager; + } + + public T transaction(final Callable work) throws Exception { + try { + if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) { + return work.call(); + } + return outerTransaction(work); + } catch (final OptimisticLockException failure) { + throw new RevisionConflictException(); + } catch (final PersistenceException + | jakarta.transaction.SystemException + | jakarta.transaction.RollbackException + | jakarta.transaction.HeuristicMixedException + | jakarta.transaction.HeuristicRollbackException + | jakarta.transaction.NotSupportedException failure) { + if (optimisticConflict(failure)) { + throw new RevisionConflictException(); + } + System.getLogger(Database.class.getName()) + .log(System.Logger.Level.WARNING, "JPA transaction failed", failure); + throw new StorageException(failure); + } + } + + private static boolean optimisticConflict(final Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof OptimisticLockException) { + return true; + } + } + return false; + } + + private T outerTransaction(final Callable work) throws Exception { + final List publications = new ArrayList<>(); + manager.begin(); + committed.set(publications); + rolledBack.set(new ArrayList<>()); + final T result; + try { + result = work.call(); + manager.commit(); + } catch (final Exception | Error failure) { + rollback(failure); + rolledBack.get().forEach(Runnable::run); + throw failure; + } finally { + committed.remove(); + rolledBack.remove(); + } + publications.forEach(Runnable::run); + return result; + } + + private void rollback(final Throwable failure) { + try { + if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) { + manager.rollback(); + } + } catch (final jakarta.transaction.SystemException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + + public void afterCommit(final Runnable action) { + committed.get().add(action); + } + + public void afterRollback(final Runnable action) { + rolledBack.get().add(action); + } + + public long nextId() { + return entities.find(EntityIdentifiers.class, 1, LockModeType.PESSIMISTIC_WRITE) + .claim(); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java new file mode 100644 index 0000000..51c0f26 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java @@ -0,0 +1,62 @@ +package nl.agilecc.agiledancers.repository; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.repository.entity.CoachEntity; +import nl.agilecc.agiledancers.repository.entity.GroupEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolMemberId; + +public class GroupMapping { + public static Map groups( + final SchoolEntity stored, + final DanceSchool school, + final Map coaches, + final Map dancers) { + final Map result = new LinkedHashMap<>(); + for (final GroupEntity entity : stored.getGroups()) { + final DanceGroup group = DanceGroup.builder() + .category(entity.getCategory()) + .type(entity.getType()) + .startAge(entity.getStartAge()) + .endAge(entity.getEndAge()) + .coach( + entity.getCoach() == null + ? null + : coaches.get(entity.getCoach().getId().getLocalId())) + .styles(new LinkedHashSet<>(entity.getStyles())) + .build(); + group.setId(entity.getId().getLocalId()); + entity.getDancerIds().stream().sorted().forEach((final Long id) -> group.addDancer(dancers.get(id))); + school.addDanceGroup(group); + result.put(group.getId(), group); + } + return result; + } + + public static GroupEntity group(final SchoolEntity school, final DanceGroup group) { + final GroupEntity entity = new GroupEntity(); + entity.setId(new SchoolMemberId(school.getId(), group.getId())); + entity.setSchool(school); + entity.setCategory(group.getCategory()); + entity.setType(group.getType()); + entity.setStartAge(group.getStartAge()); + entity.setEndAge(group.getEndAge()); + if (group.getCoach() != null) { + entity.setCoach(school.getCoaches().stream() + .filter((final CoachEntity coach) -> + coach.getId().getLocalId() == group.getCoach().getId()) + .findFirst() + .orElseThrow()); + } + entity.setStyles(new LinkedHashSet<>(group.getStyles())); + group.getDancers() + .forEach((final Dancer dancer) -> entity.getDancerIds().add(dancer.getId())); + return entity; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java new file mode 100644 index 0000000..71d42ae --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java @@ -0,0 +1,70 @@ +package nl.agilecc.agiledancers.repository; + +import java.util.LinkedHashMap; +import java.util.Map; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.repository.entity.CoachEntity; +import nl.agilecc.agiledancers.repository.entity.DancerEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolMemberId; + +/** Converts people while preserving the shared dancer identity used by coach links. */ +public class PeopleMapping { + public static Map dancers(final SchoolEntity stored, final DanceSchool school) { + final Map result = new LinkedHashMap<>(); + for (final DancerEntity person : stored.getDancers()) { + final Dancer dancer = Dancer.builder() + .name(person.getName()) + .birthDate(person.getBirthDate()) + .build(); + dancer.setId(person.getId().getLocalId()); + school.addDancer(dancer); + result.put(dancer.getId(), dancer); + } + return result; + } + + public static Map coaches( + final SchoolEntity stored, final DanceSchool school, final Map dancers) { + final Map result = new LinkedHashMap<>(); + for (final CoachEntity trainer : stored.getCoaches()) { + final Coach coach = Coach.builder() + .name(trainer.getName()) + .dancer( + trainer.getDancer() == null + ? null + : dancers.get(trainer.getDancer().getId().getLocalId())) + .build(); + coach.setId(trainer.getId().getLocalId()); + school.addCoach(coach); + result.put(coach.getId(), coach); + } + return result; + } + + public static DancerEntity dancer(final SchoolEntity school, final Dancer dancer) { + final DancerEntity entity = new DancerEntity(); + entity.setId(new SchoolMemberId(school.getId(), dancer.getId())); + entity.setSchool(school); + entity.setName(dancer.getName()); + entity.setBirthDate(dancer.getBirthDate()); + return entity; + } + + public static CoachEntity coach(final SchoolEntity school, final Coach coach) { + final CoachEntity entity = new CoachEntity(); + entity.setId(new SchoolMemberId(school.getId(), coach.getId())); + entity.setSchool(school); + entity.setName(coach.getName()); + if (coach.getDancer() != null) { + entity.setDancer(school.getDancers().stream() + .filter((final DancerEntity dancer) -> + dancer.getId().getLocalId() == coach.getDancer().getId()) + .findFirst() + .orElseThrow()); + } + return entity; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java new file mode 100644 index 0000000..b7c6836 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java @@ -0,0 +1,8 @@ +package nl.agilecc.agiledancers.repository; + +/** An optimistic persistence conflict requires a new editor snapshot, never an automatic retry. */ +public class RevisionConflictException extends RuntimeException { + public RevisionConflictException() { + super("Deze school is gewijzigd. Herlaad de gegevens en probeer opnieuw."); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java new file mode 100644 index 0000000..a51175c --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java @@ -0,0 +1,78 @@ +package nl.agilecc.agiledancers.repository; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; +import java.util.List; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; +import nl.agilecc.agiledancers.model.scheduling.SchedulingState; +import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; +import nl.agilecc.agiledancers.repository.entity.RunEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolMemberId; + +@Singleton +public class RunRepository { + private final EntityManager entities; + + @Inject + public RunRepository(final EntityManager entities) { + this.entities = entities; + } + + public List load() { + return entities + .createQuery("from StoredRun run order by run.id.schoolId,run.id.localId", RunEntity.class) + .getResultList() + .stream() + .map(this::read) + .toList(); + } + + public void recover() { + load().stream() + .filter((final SchedulingRun run) -> run.state() == SchedulingState.RUNNING) + .forEach((final SchedulingRun run) -> save(run.completed( + SchedulingState.INTERRUPTED, + "Berekening onderbroken door een backendherstart. Geen nieuw resultaat toegepast; bestaande" + + " indeling behouden. Start de berekening opnieuw.", + null))); + } + + public void save(final SchedulingRun run) { + final RunEntity entity = new RunEntity(); + entity.setId(new SchoolMemberId(run.schoolId(), run.showId())); + entity.setRunId(run.runId()); + entity.setShowName(run.showName()); + entity.setStartedAt(run.startedAt()); + entity.setFinishedAt(run.finishedAt()); + entity.setState(run.state()); + entity.setPhase(run.progress().phase()); + entity.setFeasible(run.progress().feasible()); + entity.setMessage(run.message()); + if (run.summary() != null) { + entity.setAttendanceExcess(run.summary().attendanceExcess()); + entity.setPerformanceSpread(run.summary().performanceSpread()); + entity.setVarietyPenalty(run.summary().varietyPenalty()); + } + entities.merge(entity); + } + + private SchedulingRun read(final RunEntity entity) { + final SchedulingSummary summary = entity.getAttendanceExcess() == null + ? null + : new SchedulingSummary( + entity.getAttendanceExcess(), entity.getPerformanceSpread(), entity.getVarietyPenalty()); + return new SchedulingRun( + entity.getRunId(), + entity.getId().getSchoolId(), + entity.getId().getLocalId(), + entity.getShowName(), + entity.getStartedAt(), + entity.getFinishedAt(), + entity.getState(), + new SchedulingProgress(entity.getPhase(), entity.isFeasible()), + entity.getMessage(), + summary); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java new file mode 100644 index 0000000..05f173c --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java @@ -0,0 +1,47 @@ +package nl.agilecc.agiledancers.repository; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Map; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.repository.entity.SchoolEntity; +import nl.agilecc.agiledancers.repository.entity.ShowEntity; + +/** Exposes detached domain snapshots, keeping Hibernate sessions and lazy collections inside the adapter. */ +@Singleton +public class SchoolReader { + private final EntityManager entities; + + @Inject + public SchoolReader(final EntityManager entities) { + this.entities = entities; + } + + public List load() { + return entities + .createQuery("from StoredSchool school order by school.id", SchoolEntity.class) + .getResultList() + .stream() + .map(this::school) + .toList(); + } + + private DanceSchool school(final SchoolEntity entity) { + final DanceSchool school = DanceSchool.builder() + .key(entity.getKey()) + .name(entity.getName()) + .revision(entity.getRevision()) + .build(); + school.setId(entity.getId()); + final Map dancers = PeopleMapping.dancers(entity, school); + final Map coaches = PeopleMapping.coaches(entity, school, dancers); + final Map groups = GroupMapping.groups(entity, school, coaches, dancers); + entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups))); + return school; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java new file mode 100644 index 0000000..bd31d75 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java @@ -0,0 +1,82 @@ +package nl.agilecc.agiledancers.repository; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import java.util.List; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.repository.entity.RunEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolEntity; + +/** Hibernate merges the aggregate graph and handles child differences, orphan removal and optimistic versioning. */ +@Singleton +public class SchoolWriter { + private final EntityManager entities; + + @Inject + public SchoolWriter(final EntityManager entities) { + this.entities = entities; + } + + public void checkRevision(final long schoolId, final long expected) { + final SchoolEntity school = entities.find(SchoolEntity.class, schoolId, LockModeType.OPTIMISTIC); + if (school == null || school.getRevision() != expected) { + throw new RevisionConflictException(); + } + } + + public void save(final DanceSchool school) { + final SchoolEntity existing = entities.find(SchoolEntity.class, school.getId()); + final SchoolEntity graph = graph(school); + if (existing == null) { + entities.persist(graph); + } else { + removeDeletedRuns(school); + graph.setRevision(existing.getRevision()); + graph.setChangeStamp(existing.getChangeStamp() + 1); + entities.merge(graph); + } + entities.flush(); + } + + private SchoolEntity graph(final DanceSchool school) { + final SchoolEntity graph = new SchoolEntity(); + graph.setId(school.getId()); + graph.setKey(school.getKey()); + graph.setName(school.getName()); + school.getDancers() + .forEach((final Dancer dancer) -> graph.getDancers().add(PeopleMapping.dancer(graph, dancer))); + school.getCoaches().forEach((final Coach coach) -> graph.getCoaches().add(PeopleMapping.coach(graph, coach))); + school.getDanceGroups() + .forEach((final DanceGroup group) -> graph.getGroups().add(GroupMapping.group(graph, group))); + school.getShows().forEach((final Show show) -> graph.getShows().add(ShowMapping.show(graph, show))); + return graph; + } + + private void removeDeletedRuns(final DanceSchool school) { + final List shows = school.getShows().stream().map(Show::getId).toList(); + entities + .createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class) + .setParameter("school", school.getId()) + .getResultList() + .stream() + .filter((final RunEntity run) -> !shows.contains(run.getId().getLocalId())) + .forEach(entities::remove); + } + + public void delete(final long schoolId) { + entities.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class) + .setParameter("school", schoolId) + .getResultList() + .forEach(entities::remove); + final SchoolEntity school = entities.find(SchoolEntity.class, schoolId); + if (school != null) { + entities.remove(school); + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java new file mode 100644 index 0000000..a49d264 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java @@ -0,0 +1,59 @@ +package nl.agilecc.agiledancers.repository; + +import java.util.LinkedHashMap; +import java.util.Map; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.repository.entity.DayPartEntity; +import nl.agilecc.agiledancers.repository.entity.DayPartId; +import nl.agilecc.agiledancers.repository.entity.SchoolEntity; +import nl.agilecc.agiledancers.repository.entity.SchoolMemberId; +import nl.agilecc.agiledancers.repository.entity.ShowEntity; + +public class ShowMapping { + public static Show show(final ShowEntity entity, final Map groups) { + final Show show = Show.builder() + .name(entity.getName()) + .preferredDayParts(entity.getPreferredDayParts()) + .finalized(entity.isFinalized()) + .appearances(Map.copyOf(entity.getAppearances())) + .dayParts(entity.getDayParts().stream() + .map((final DayPartEntity part) -> DayPart.builder() + .date(part.getDate()) + .type(part.getType()) + .danceGroups(part.getGroupIds().stream() + .map(groups::get) + .toList()) + .build()) + .toList()) + .build(); + show.setId(entity.getId().getLocalId()); + return show; + } + + public static ShowEntity show(final SchoolEntity school, final Show show) { + final ShowEntity entity = new ShowEntity(); + entity.setId(new SchoolMemberId(school.getId(), show.getId())); + entity.setSchool(school); + entity.setName(show.getName()); + entity.setPreferredDayParts(show.getPreferredDayParts()); + entity.setFinalized(show.isFinalized()); + entity.setAppearances(new LinkedHashMap<>(show.getAppearances())); + for (int index = 0; index < show.getDayParts().size(); index++) { + entity.getDayParts().add(part(entity, index, show.getDayParts().get(index))); + } + return entity; + } + + private static DayPartEntity part(final ShowEntity show, final int position, final DayPart part) { + final DayPartEntity entity = new DayPartEntity(); + entity.setId(new DayPartId(show.getId().getSchoolId(), show.getId().getLocalId(), position)); + entity.setShow(show); + entity.setDate(part.getDate()); + entity.setType(part.getType()); + part.getDanceGroups() + .forEach((final DanceGroup group) -> entity.getGroupIds().add(group.getId())); + return entity; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java new file mode 100644 index 0000000..f962ed6 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java @@ -0,0 +1,8 @@ +package nl.agilecc.agiledancers.repository; + +/** Storage failures never authorize an in-memory fallback. */ +public class StorageException extends RuntimeException { + public StorageException(final Exception cause) { + super("Database niet beschikbaar. De wijziging is niet opgeslagen; herlaad en probeer opnieuw.", cause); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java new file mode 100644 index 0000000..a3d7406 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java @@ -0,0 +1,33 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity(name = "StoredCoach") +@Table(name = "coach") +public class CoachEntity extends SchoolMemberEntity { + @Column(nullable = false) + private String name; + + // The shared school key is read from the owning row; only the linked member ID is writable. + @ManyToOne( + fetch = FetchType.LAZY, + cascade = {CascadeType.MERGE, CascadeType.PERSIST}) + @org.hibernate.annotations.JoinColumnsOrFormulas({ + @org.hibernate.annotations.JoinColumnOrFormula( + column = @JoinColumn(name = "dancer_id", referencedColumnName = "id")), + @org.hibernate.annotations.JoinColumnOrFormula( + formula = + @org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id")) + }) + private DancerEntity dancer; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java new file mode 100644 index 0000000..1ad35ca --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java @@ -0,0 +1,20 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity(name = "StoredDancer") +@Table(name = "dancer") +public class DancerEntity extends SchoolMemberEntity { + @Column(nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java new file mode 100644 index 0000000..f909208 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java @@ -0,0 +1,56 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinColumns; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import nl.agilecc.agiledancers.model.specifications.DayPartType; + +@Getter +@Setter +@Entity(name = "StoredDayPart") +@Table(name = "day_part") +public class DayPartEntity { + @EmbeddedId + private DayPartId id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumns({ + @JoinColumn(name = "school_id", referencedColumnName = "school_id", insertable = false, updatable = false), + @JoinColumn(name = "show_id", referencedColumnName = "id", insertable = false, updatable = false) + }) + private ShowEntity show; + + @Column(name = "day", nullable = false) + private LocalDate date; + + @Enumerated(EnumType.STRING) + @Column(name = "part_type", nullable = false) + private DayPartType type; + + @ElementCollection + @CollectionTable( + name = "assignment", + joinColumns = { + @JoinColumn(name = "school_id", referencedColumnName = "school_id"), + @JoinColumn(name = "show_id", referencedColumnName = "show_id"), + @JoinColumn(name = "part_position", referencedColumnName = "position") + }) + @OrderColumn(name = "position") + @Column(name = "group_id", nullable = false) + private List groupIds = new ArrayList<>(); +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java new file mode 100644 index 0000000..d394e26 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java @@ -0,0 +1,26 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Embeddable +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +public class DayPartId implements Serializable { + @jakarta.persistence.Column(name = "school_id") + private long schoolId; + + @jakarta.persistence.Column(name = "show_id") + private long showId; + + @jakarta.persistence.Column(name = "position") + private int position; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java new file mode 100644 index 0000000..bf3af64 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java @@ -0,0 +1,24 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity(name = "EntityIdentifiers") +@Table(name = "entity_identifiers") +public class EntityIdentifiers { + @Id + private int id; + + @Column(name = "next_value", nullable = false) + private long nextValue; + + public long claim() { + return nextValue++; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java new file mode 100644 index 0000000..f2a99df --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java @@ -0,0 +1,72 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; + +@Getter +@Setter +@Entity(name = "StoredGroup") +@Table(name = "dance_group") +public class GroupEntity extends SchoolMemberEntity { + @Column(nullable = false) + private String category; + + @Enumerated(EnumType.STRING) + @Column(name = "group_type", nullable = false) + private DanceGroupType type; + + @Column(name = "start_age", nullable = false) + private int startAge; + + @Column(name = "end_age", nullable = false) + private int endAge; + + // The shared school key is read from the owning row; only the linked member ID is writable. + @ManyToOne( + fetch = FetchType.LAZY, + cascade = {CascadeType.MERGE, CascadeType.PERSIST}) + @org.hibernate.annotations.JoinColumnsOrFormulas({ + @org.hibernate.annotations.JoinColumnOrFormula( + column = @JoinColumn(name = "coach_id", referencedColumnName = "id")), + @org.hibernate.annotations.JoinColumnOrFormula( + formula = + @org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id")) + }) + private CoachEntity coach; + + @ElementCollection + @CollectionTable( + name = "group_style", + joinColumns = { + @JoinColumn(name = "school_id", referencedColumnName = "school_id"), + @JoinColumn(name = "group_id", referencedColumnName = "id") + }) + @Enumerated(EnumType.STRING) + @Column(name = "style", nullable = false) + private Set styles = new LinkedHashSet<>(); + + @ElementCollection + @CollectionTable( + name = "group_dancer", + joinColumns = { + @JoinColumn(name = "school_id", referencedColumnName = "school_id"), + @JoinColumn(name = "group_id", referencedColumnName = "id") + }) + @Column(name = "dancer_id", nullable = false) + private Set dancerIds = new LinkedHashSet<>(); +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java new file mode 100644 index 0000000..f8485c7 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java @@ -0,0 +1,59 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; +import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase; +import nl.agilecc.agiledancers.model.scheduling.SchedulingState; + +@Getter +@Setter +@Entity(name = "StoredRun") +@Table(name = "scheduling_run") +public class RunEntity { + @EmbeddedId + @AttributeOverride(name = "localId", column = @Column(name = "show_id")) + private SchoolMemberId id; + + @Column(name = "run_id", nullable = false) + private UUID runId; + + @Column(name = "show_name", nullable = false) + private String showName; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "finished_at") + private Instant finishedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SchedulingState state; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SchedulingPhase phase; + + private boolean feasible; + + @Column(nullable = false) + private String message; + + @Column(name = "attendance_excess") + private Long attendanceExcess; + + @Column(name = "performance_spread") + private Integer performanceSpread; + + @Column(name = "variety_penalty") + private Long varietyPenalty; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java new file mode 100644 index 0000000..d5dd760 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java @@ -0,0 +1,52 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity(name = "StoredSchool") +@Table(name = "school") +public class SchoolEntity { + @Id + private long id; + + @Column(name = "school_key", nullable = false) + private UUID key; + + @Column(nullable = false) + private String name; + + @Version + private long revision; + // Marks child-only changes so Hibernate advances the aggregate version exactly once. + @Column(name = "change_stamp", nullable = false) + private long changeStamp; + + @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("id.localId") + private List dancers = new ArrayList<>(); + + @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("id.localId") + private List coaches = new ArrayList<>(); + + @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("id.localId") + private List groups = new ArrayList<>(); + + @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("id.localId") + private List shows = new ArrayList<>(); +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java new file mode 100644 index 0000000..79aa4c8 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java @@ -0,0 +1,21 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@MappedSuperclass +public abstract class SchoolMemberEntity { + @EmbeddedId + private SchoolMemberId id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "school_id", insertable = false, updatable = false) + private SchoolEntity school; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java new file mode 100644 index 0000000..bc0a9f1 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java @@ -0,0 +1,23 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Embeddable +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +public class SchoolMemberId implements Serializable { + @jakarta.persistence.Column(name = "school_id") + private long schoolId; + + @jakarta.persistence.Column(name = "id") + private long localId; +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java new file mode 100644 index 0000000..fbf6ef5 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java @@ -0,0 +1,51 @@ +package nl.agilecc.agiledancers.repository.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapKeyColumn; +import jakarta.persistence.MapKeyEnumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; + +@Getter +@Setter +@Entity(name = "StoredShow") +@Table(name = "show") +public class ShowEntity extends SchoolMemberEntity { + @Column(nullable = false) + private String name; + + @Column(name = "preferred_day_parts", nullable = false) + private int preferredDayParts; + + private boolean finalized; + + @ElementCollection + @CollectionTable( + name = "show_appearance", + joinColumns = { + @JoinColumn(name = "school_id", referencedColumnName = "school_id"), + @JoinColumn(name = "show_id", referencedColumnName = "id") + }) + @MapKeyEnumerated(EnumType.STRING) + @MapKeyColumn(name = "group_type") + @Column(name = "appearances", nullable = false) + private Map appearances = new LinkedHashMap<>(); + + @OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("id.position") + private List dayParts = new ArrayList<>(); +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java index 82fddae..502b7e3 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java @@ -15,29 +15,33 @@ import jakarta.ws.rs.core.MediaType; import java.util.List; import nl.agilecc.agiledancers.rest.apimodel.CoachRequest; import nl.agilecc.agiledancers.rest.apimodel.CoachView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.CoachLinks; +@SchoolTransactionBoundary @Path("/api/dance-schools/{schoolId}/coaches") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class CoachResource { + private @Inject DomainViewMapper views; + private @Inject CoachLinks links; private @PathParam("schoolId") long schoolId; @GET public List list() { - return links.list(schoolId).stream().map(CoachView::of).toList(); + return links.list(schoolId).stream().map(views::coach).toList(); } @POST public CoachView create(final @NotNull @Valid CoachRequest request) { - return CoachView.of(links.link(schoolId, null, request.dancerId())); + return views.coach(links.link(schoolId, null, request.dancerId())); } @PUT @Path("/{id}") public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) { - return CoachView.of(links.link(schoolId, id, request.dancerId())); + return views.coach(links.link(schoolId, id, request.dancerId())); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java index c79f423..3b985be 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java @@ -19,26 +19,30 @@ import java.util.List; import nl.agilecc.agiledancers.model.DanceGroup; import nl.agilecc.agiledancers.rest.apimodel.GroupRequest; import nl.agilecc.agiledancers.rest.apimodel.GroupView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.model.GroupDetails; +@SchoolTransactionBoundary @Path("/api/dance-schools/{schoolId}/dance-groups") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class DanceGroupResource { + private @Inject DomainViewMapper views; + private @Inject SchoolManagementService service; private @PathParam("schoolId") long schoolId; @GET public List list() { - return service.groups(schoolId).stream().map(GroupView::of).toList(); + return service.groups(schoolId).stream().map(views::group).toList(); } @GET @Path("/{id}") public GroupView get(final @PathParam("id") long id) { - return GroupView.of(service.group(schoolId, id)); + return views.group(service.group(schoolId, id)); } @POST @@ -50,14 +54,14 @@ public class DanceGroupResource { return Response.created(uriInfo.getAbsolutePathBuilder() .path(entity.getId().toString()) .build()) - .entity(GroupView.of(entity)) + .entity(views.group(entity)) .build(); } @PUT @Path("/{id}") public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) { - return GroupView.of(service.updateGroup( + return views.group(service.updateGroup( schoolId, id, new GroupDetails( diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java index b69fea4..a55a505 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java @@ -19,23 +19,44 @@ import java.util.List; import nl.agilecc.agiledancers.model.DanceSchool; import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest; import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.SchoolManagementService; +@SchoolTransactionBoundary @Path("/api/dance-schools") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class DanceSchoolResource { + private @Inject DomainViewMapper views; + private @Inject SchoolManagementService service; + private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules; + + @GET + @Path("/{id}/data") + public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) { + final DanceSchool school = service.school(id); + return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView( + views.school(school), + school.getDancers().stream().map(views::dancer).toList(), + school.getDanceGroups().stream().map(views::group).toList(), + school.getShows().stream() + .map((final nl.agilecc.agiledancers.model.Show show) -> + views.show(show, schedules.violations(id, show.getId()))) + .toList(), + school.getCoaches().stream().map(views::coach).toList()); + } + @GET public List list() { - return service.schools().stream().map(SchoolView::of).toList(); + return service.schools().stream().map(views::school).toList(); } @GET @Path("/{id}") public SchoolView get(final @PathParam("id") long id) { - return SchoolView.of(service.school(id)); + return views.school(service.school(id)); } @POST @@ -44,14 +65,14 @@ public class DanceSchoolResource { return Response.created(uriInfo.getAbsolutePathBuilder() .path(entity.getId().toString()) .build()) - .entity(SchoolView.of(entity)) + .entity(views.school(entity)) .build(); } @PUT @Path("/{id}") public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) { - return SchoolView.of(service.updateSchool(id, request.name())); + return views.school(service.updateSchool(id, request.name())); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java index 9d34588..4bbb895 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java @@ -19,25 +19,29 @@ import java.util.List; import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.rest.apimodel.DancerRequest; import nl.agilecc.agiledancers.rest.apimodel.DancerView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.SchoolManagementService; +@SchoolTransactionBoundary @Path("/api/dance-schools/{schoolId}/dancers") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class DancerResource { + private @Inject DomainViewMapper views; + private @Inject SchoolManagementService service; private @PathParam("schoolId") long schoolId; @GET public List list() { - return service.dancers(schoolId).stream().map(DancerView::of).toList(); + return service.dancers(schoolId).stream().map(views::dancer).toList(); } @GET @Path("/{id}") public DancerView get(final @PathParam("id") long id) { - return DancerView.of(service.dancer(schoolId, id)); + return views.dancer(service.dancer(schoolId, id)); } @POST @@ -46,14 +50,14 @@ public class DancerResource { return Response.created(uriInfo.getAbsolutePathBuilder() .path(entity.getId().toString()) .build()) - .entity(DancerView.of(entity)) + .entity(views.dancer(entity)) .build(); } @PUT @Path("/{id}") public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) { - return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate())); + return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate())); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java index 89183ec..57e37c5 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java @@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper 404; + case STORAGE_UNAVAILABLE -> 503; case INVALID_INPUT -> 400; case CONFLICT -> 409; }; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java index 7f90dfe..5357cd6 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java @@ -9,9 +9,9 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import java.time.Duration; import java.util.List; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView; import nl.agilecc.agiledancers.service.SchedulingRuns; -import nl.agilecc.agiledancers.service.model.SchedulingRun; import org.jboss.resteasy.reactive.RestStreamElementType; @Path("/api/scheduling/events") @@ -36,6 +36,9 @@ public class SchedulingEventsResource { .every(Duration.ofSeconds(15)) .onItem() .transform((final Long tick) -> new SchedulingEventView(null)); - return Multi.createBy().merging().streams(updates, heartbeat); + return Multi.createBy() + .merging() + .streams(updates, heartbeat) + .runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool()); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java new file mode 100644 index 0000000..0aa77bf --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java @@ -0,0 +1,50 @@ +package nl.agilecc.agiledancers.rest; + +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import nl.agilecc.agiledancers.service.ManagementException; +import nl.agilecc.agiledancers.service.ManagementExceptionKind; +import nl.agilecc.agiledancers.service.SchoolTransactions; +import nl.agilecc.agiledancers.service.model.SchoolChange; + +@SchoolTransactionBoundary +@Interceptor +@Priority(Interceptor.Priority.APPLICATION) +public class SchoolRequestInterceptor { + private static final Pattern SCHOOL_PATH = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)"); + private @Inject RoutingContext context; + private @Inject SchoolTransactions transactions; + + @AroundInvoke + public Object invoke(final InvocationContext invocation) throws Exception { + final String path = context.request().path(); + final String method = context.request().method().name(); + final boolean mutation = !method.equals("GET") && !path.endsWith("/preview"); + final Matcher matcher = SCHOOL_PATH.matcher(path); + final Long schoolId = matcher.find() + ? Long.valueOf(matcher.group(1)) + : number(context.request().getParam("replaceId")); + final Long revision = mutation ? number(context.request().getHeader("X-School-Revision")) : null; + return transactions.request( + new SchoolChange(schoolId, revision, mutation, mutation && !path.endsWith("/schedule")), + invocation::proceed); + } + + private static Long number(final String value) { + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (final NumberFormatException failure) { + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, "Ongeldige schoolversie of school-ID."); + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java index 7416e6b..be58524 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java @@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.ManagementException; import nl.agilecc.agiledancers.service.ManagementExceptionKind; import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.model.ImportPreview; import nl.agilecc.agiledancers.service.model.SchoolSnapshot; +@SchoolTransactionBoundary @Path("/api/dance-schools") @Produces(MediaType.APPLICATION_JSON) public class SchoolSnapshotResource { + private @Inject DomainViewMapper views; + private @Inject SchoolManagementService service; private @Inject ObjectMapper mapper; @@ -48,7 +52,7 @@ public class SchoolSnapshotResource { @Path("/import") @Consumes(MediaType.APPLICATION_JSON) public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) { - return SchoolView.of(service.importSchool(parse(json), replaceId)); + return views.school(service.importSchool(parse(json), replaceId)); } private SchoolSnapshot parse(final String json) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java new file mode 100644 index 0000000..a6448ba --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java @@ -0,0 +1,13 @@ +package nl.agilecc.agiledancers.rest; + +import jakarta.interceptor.InterceptorBinding; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Bounds school HTTP operations to one durable transaction and optimistic revision check. */ +@InterceptorBinding +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface SchoolTransactionBoundary {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java index 3f6bf57..eb9d186 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java @@ -18,19 +18,23 @@ import jakarta.ws.rs.core.UriInfo; import java.util.List; import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest; import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; import nl.agilecc.agiledancers.service.SchedulingRuns; import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.ShowSchedules; -import nl.agilecc.agiledancers.service.model.SchedulingRun; +@SchoolTransactionBoundary @Path("/api/dance-schools/{schoolId}/shows") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class ShowResource { + private @Inject DomainViewMapper views; + private @Inject SchoolManagementService service; private @Inject SchedulingRuns scheduling; @@ -67,7 +71,7 @@ public class ShowResource { } private ShowView view(final Show show) { - return ShowView.of(show, schedules.violations(schoolId, show.getId())); + return views.show(show, schedules.violations(schoolId, show.getId())); } private static Show settings(final ShowRequest request) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java index 8eb94af..cf49796 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java @@ -1,12 +1,3 @@ package nl.agilecc.agiledancers.rest.apimodel; -import nl.agilecc.agiledancers.model.Coach; - -public record CoachView(long id, String name, Long dancerId) { - public static CoachView of(final Coach coach) { - return new CoachView( - coach.getId(), - coach.getName(), - coach.getDancer() == null ? null : coach.getDancer().getId()); - } -} +public record CoachView(long id, String name, Long dancerId) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java index 0631a37..3acd525 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java @@ -1,10 +1,5 @@ package nl.agilecc.agiledancers.rest.apimodel; import java.time.LocalDate; -import nl.agilecc.agiledancers.model.Dancer; -public record DancerView(long id, String name, LocalDate birthDate) { - public static DancerView of(final Dancer dancer) { - return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate()); - } -} +public record DancerView(long id, String name, LocalDate birthDate) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java index bf5f84d..29ff36c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java @@ -5,8 +5,6 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import nl.agilecc.agiledancers.model.DanceGroup; -import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; @@ -22,15 +20,4 @@ public record GroupView( styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles)); dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds)); } - - public static GroupView of(final DanceGroup group) { - return new GroupView( - group.getId(), - group.getCategory(), - group.getType(), - group.getStartAge(), - group.getEndAge(), - Set.copyOf(group.getStyles()), - group.getDancers().stream().map(Dancer::getId).toList()); - } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java index f9f1ed4..b791fec 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java @@ -1,7 +1,7 @@ package nl.agilecc.agiledancers.rest.apimodel; import java.util.List; -import nl.agilecc.agiledancers.service.model.SchedulingRun; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; /** A null runs field is a heartbeat, not a replacement of the current snapshot. */ public record SchedulingEventView(List runs) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java new file mode 100644 index 0000000..be5a33c --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java @@ -0,0 +1,18 @@ +package nl.agilecc.agiledancers.rest.apimodel; + +import java.util.List; + +/** One school and all editor data from the same revision and database snapshot. */ +public record SchoolDataView( + SchoolView school, + List dancers, + List groups, + List shows, + List coaches) { + public SchoolDataView { + dancers = List.copyOf(dancers); + groups = List.copyOf(groups); + shows = List.copyOf(shows); + coaches = List.copyOf(coaches); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java index 1f1b5ea..7d2a498 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java @@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import nl.agilecc.agiledancers.model.DanceGroup; -import nl.agilecc.agiledancers.model.DanceSchool; -import nl.agilecc.agiledancers.model.Dancer; -import nl.agilecc.agiledancers.model.Show; -public record SchoolView(long id, String name, List dancerIds, List danceGroupIds, List showIds) { +public record SchoolView( + long id, long revision, String name, List dancerIds, List danceGroupIds, List showIds) { public SchoolView { dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds)); danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds)); showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds)); } - - public static SchoolView of(final DanceSchool school) { - return new SchoolView( - school.getId(), - school.getName(), - school.getDancers().stream().map(Dancer::getId).toList(), - school.getDanceGroups().stream().map(DanceGroup::getId).toList(), - school.getShows().stream().map(Show::getId).toList()); - } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java index 3fc54a3..68d45de 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java @@ -5,9 +5,6 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import nl.agilecc.agiledancers.model.DanceGroup; -import nl.agilecc.agiledancers.model.DayPart; -import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; public record ShowView( @@ -23,26 +20,4 @@ public record ShowView( dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); } - - public static ShowView of(final Show show) { - return of(show, List.of()); - } - - public static ShowView of(final Show show, final List violations) { - return new ShowView( - show.getId(), - show.getName(), - show.getDayParts().stream() - .map((final DayPart p) -> new DayPartView( - p.getDate(), - p.getType(), - p.getDanceGroups().stream() - .map(DanceGroup::getId) - .toList())) - .toList(), - show.getAppearances(), - nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show), - show.isFinalized(), - violations); - } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java new file mode 100644 index 0000000..f34a74e --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java @@ -0,0 +1,59 @@ +package nl.agilecc.agiledancers.rest.mapping; + +import java.util.List; +import nl.agilecc.agiledancers.model.AbstractEntity; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.rest.apimodel.CoachView; +import nl.agilecc.agiledancers.rest.apimodel.DancerView; +import nl.agilecc.agiledancers.rest.apimodel.DayPartView; +import nl.agilecc.agiledancers.rest.apimodel.GroupView; +import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.rest.apimodel.ShowView; +import nl.agilecc.agiledancers.service.SchedulingInputs; +import org.mapstruct.AnnotateWith; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; +import org.mapstruct.ReportingPolicy; + +/** Generates API projections only; domain graph identity and validation remain application-owned. */ +// Mark generated implementations so coverage and architecture checks retain their handwritten-code scope. +@AnnotateWith(lombok.Generated.class) +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, unmappedTargetPolicy = ReportingPolicy.ERROR) +public interface DomainViewMapper { + @Mapping(target = "dancerIds", source = "dancers") + @Mapping(target = "danceGroupIds", source = "danceGroups") + @Mapping(target = "showIds", source = "shows") + public SchoolView school(final DanceSchool school); + + public DancerView dancer(final Dancer dancer); + + @Mapping(target = "dancerIds", source = "dancers") + public GroupView group(final DanceGroup group); + + @Mapping(target = "dancerId", source = "dancer") + public CoachView coach(final Coach coach); + + @Mapping(target = "danceGroupIds", source = "danceGroups") + public DayPartView dayPart(final DayPart part); + + /** Violations are calculated by the use case, never inferred by the mapper. */ + @Mapping(target = "violations", source = "violations") + @Mapping(target = "preferredDayParts", source = "show", qualifiedByName = "preferredDayParts") + public ShowView show(final Show show, final List violations); + + public default Long id(final AbstractEntity entity) { + return entity == null ? null : entity.getId(); + } + + @Named("preferredDayParts") + public default int preferredDayParts(final Show show) { + return SchedulingInputs.preferred(show); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java index 1e3a6ea..a98ea3b 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java @@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service; public enum ManagementExceptionKind { NOT_FOUND, + STORAGE_UNAVAILABLE, INVALID_INPUT, CONFLICT } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java new file mode 100644 index 0000000..71d51dd --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java @@ -0,0 +1,54 @@ +package nl.agilecc.agiledancers.service; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.concurrent.Callable; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; +import nl.agilecc.agiledancers.repository.Database; +import nl.agilecc.agiledancers.repository.RunRepository; + +/** Coordinates scheduling status with the same unit of work as domain changes. */ +@Singleton +public class SchedulingPersistence { + private final SchoolTransactions transactions; + private final Database database; + private final RunRepository runs; + + @Inject + public SchedulingPersistence( + final SchoolTransactions transactions, final Database database, final RunRepository runs) { + this.transactions = transactions; + this.database = database; + this.runs = runs; + } + + public List recover() { + return transactions.background(() -> { + runs.recover(); + return runs.load(); + }); + } + + public List load() { + return transactions.background(runs::load); + } + + public T work(final Callable action) { + return transactions.background(action); + } + + public void save(final SchedulingRun run, final Runnable publication) { + runs.save(run); + database.afterCommit(publication); + } + + public void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) { + database.afterRollback(rollback); + save(run, launch); + } + + public void result(final long schoolId) { + transactions.saveResult(schoolId); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java index 2687d0d..797f6f5 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java @@ -1,23 +1,27 @@ package nl.agilecc.agiledancers.service; +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; +import nl.agilecc.agiledancers.model.scheduling.SchedulingState; import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; -import nl.agilecc.agiledancers.service.model.SchedulingRun; -import nl.agilecc.agiledancers.service.model.SchedulingState; import nl.agilecc.agiledancers.service.port.SchedulingEngine; -/** Owns background tasks and publishes current state independently of any HTTP connection. */ +/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */ +@Startup @Singleton public class SchedulingRuns { private final SchoolManagementService schools; @@ -25,6 +29,7 @@ public class SchedulingRuns { private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); private final Map runs = new HashMap<>(); private final Map>> listeners = new HashMap<>(); + private @Inject SchedulingPersistence persistence; @Inject public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) { @@ -32,74 +37,119 @@ public class SchedulingRuns { this.engine = engine; } - public synchronized SchedulingRun start(final long schoolId, final long showId) { - final SchedulingInput input; - final SchedulingRun run; + @PostConstruct + public void recover() { + persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run)); + } + + public SchedulingRun start(final long schoolId, final long showId) { synchronized (schools) { - final String name = schools.show(schoolId, showId).getName(); - input = schools.beginScheduling(schoolId, showId); - run = SchedulingRun.started(schoolId, showId, name); + final SchedulingInput input = schools.beginScheduling(schoolId, showId); + final SchedulingRun run = SchedulingRun.started( + schoolId, showId, schools.show(schoolId, showId).getName()); + final Runnable launch = () -> launch(run, input); + if (persistence == null) { + launch.run(); + } else { + persistence.accepted(run, launch, () -> schools.endScheduling(showId)); + } + return run; } + } + + private void launch(final SchedulingRun run, final SchedulingInput input) { publish(run); try { executor.submit(() -> calculate(run, input)); } catch (final RuntimeException failure) { - schools.endScheduling(showId); - publish(run.completed( + finish(run.completed( SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null)); - throw failure; + if (persistence == null) { + throw failure; + } } - return run; } - public synchronized SchedulingRun status(final long schoolId, final long showId) { - schools.show(schoolId, showId); - return runs.get(showId); + public SchedulingRun status(final long schoolId, final long showId) { + synchronized (schools) { + schools.show(schoolId, showId); + return runs.get(showId); + } } /** Registration and initial snapshot share the publication lock, preventing a missed update. */ - public synchronized Runnable subscribe(final Consumer> listener) { - final Object key = new Object(); - listeners.put(key, listener); - listener.accept(List.copyOf(runs.values())); - return () -> unsubscribe(key); + public Runnable subscribe(final Consumer> listener) { + synchronized (schools) { + if (persistence != null) { + runs.clear(); + persistence.load().forEach((final SchedulingRun run) -> runs.put(run.showId(), run)); + } + final Object key = new Object(); + listeners.put(key, listener); + listener.accept(List.copyOf(runs.values())); + return () -> unsubscribe(key); + } } - private synchronized void unsubscribe(final Object key) { - listeners.remove(key); + private void unsubscribe(final Object key) { + synchronized (schools) { + listeners.remove(key); + } } - private synchronized void publish(final SchedulingRun run) { - runs.put(run.showId(), run); - final List snapshot = List.copyOf(runs.values()); - listeners.values().forEach((final Consumer> listener) -> listener.accept(snapshot)); + private void publish(final SchedulingRun run) { + synchronized (schools) { + runs.put(run.showId(), run); + final List snapshot = List.copyOf(runs.values()); + for (final Consumer> listener : List.copyOf(listeners.values())) { + notifyListener(listener, snapshot); + } + } } - private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) { - publish(runs.get(run.showId()).progressed(update)); + private void notifyListener(final Consumer> listener, final List snapshot) { + try { + listener.accept(snapshot); + } catch (final RuntimeException disconnected) { + // A disconnected transport must not turn a committed solver outcome into a failure. + listeners.values().remove(listener); + } + } + + private void progress(final SchedulingRun run, final SchedulingProgress update) { + work(() -> { + save(latest(run).progressed(update)); + return null; + }); } private void calculate(final SchedulingRun run, final SchedulingInput input) { - SchedulingRun completed; try { final SchedulingResult result = engine.solve(input, (final SchedulingProgress update) -> progress(run, update)); final SchedulingSummary summary = SchedulingSummary.of(input, result); - schools.finishScheduling(run.schoolId(), input, result); - completed = latest(run) - .completed( - result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID, - result.optimal() - ? "Optimaal conceptrooster." - : "Geldig conceptrooster; optimaliteit niet bewezen.", - summary); + work(() -> { + schools.finishScheduling(run.schoolId(), input, result); + if (persistence != null) { + persistence.result(run.schoolId()); + } + save(latest(run) + .completed( + result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID, + result.optimal() + ? "Optimaal conceptrooster." + : "Geldig conceptrooster; optimaliteit niet bewezen.", + summary)); + return null; + }); + schools.endScheduling(run.showId()); } catch (final ManagementException failure) { - completed = failed(run, failure); + finish(failed(run, failure)); } catch (final RuntimeException | LinkageError failure) { - completed = latest(run) - .completed(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null); + finish(latest(run) + .completed( + SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null)); } - finish(completed); } private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) { @@ -115,13 +165,44 @@ public class SchedulingRuns { return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null); } - private synchronized SchedulingRun latest(final SchedulingRun run) { - return runs.get(run.showId()); + private SchedulingRun latest(final SchedulingRun run) { + synchronized (schools) { + return runs.get(run.showId()); + } } - private synchronized void finish(final SchedulingRun completed) { - schools.endScheduling(completed.showId()); - publish(completed); + private void finish(final SchedulingRun completed) { + try { + work(() -> { + save(completed); + return null; + }); + } finally { + schools.endScheduling(completed.showId()); + } + } + + private void save(final SchedulingRun run) { + if (persistence == null) { + publish(run); + } else { + persistence.save(run, () -> publish(run)); + } + } + + private T work(final Callable action) { + synchronized (schools) { + if (persistence != null) { + return persistence.work(action); + } + try { + return action.call(); + } catch (final RuntimeException failure) { + throw failure; + } catch (final Exception failure) { + throw new IllegalStateException(failure); + } + } } @PreDestroy diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java index 88eebe4..207e269 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java @@ -30,7 +30,7 @@ import nl.agilecc.agiledancers.service.model.GroupDetails; import nl.agilecc.agiledancers.service.model.ImportPreview; import nl.agilecc.agiledancers.service.model.SchoolSnapshot; -/** Owns the in-memory school aggregates; all writes go through this shared service. */ +/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */ @Singleton public class SchoolManagementService { private final DanceSchoolRepository schools = new DanceSchoolRepository(); @@ -41,6 +41,27 @@ public class SchoolManagementService { private final Map revisions = new HashMap<>(); private final Set running = new HashSet<>(); + /** Installs a detached relational snapshot under the service's existing aggregate lock. */ + public synchronized void load(final List state, final java.util.function.LongSupplier identifiers) { + revisions.clear(); + schools.load(state, identifiers); + dancers.load( + state.stream() + .flatMap((final DanceSchool school) -> school.getDancers().stream()) + .toList(), + identifiers); + groups.load( + state.stream() + .flatMap((final DanceSchool school) -> school.getDanceGroups().stream()) + .toList(), + identifiers); + shows.load( + state.stream() + .flatMap((final DanceSchool school) -> school.getShows().stream()) + .toList(), + identifiers); + } + public synchronized void touch(final Show show) { show.setFinalized(false); revisions.merge(show.getId(), 1L, Long::sum); @@ -64,7 +85,8 @@ public class SchoolManagementService { ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling."); } running.add(showId); - return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L)); + return SchedulingInputs.of( + school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L)); } /** Validates the complete result and input revision before replacing any saved assignments. */ @@ -77,7 +99,7 @@ public class SchoolManagementService { throw new ManagementException( ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again."); } - if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) { + if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) { throw new ManagementException( ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again."); } @@ -320,12 +342,14 @@ public class SchoolManagementService { snapshot.groups().size(), snapshot.shows().size(), existing == null ? null : existing.getId(), - existing == null ? null : existing.getName()); + existing == null ? null : existing.getName(), + existing == null ? null : existing.getRevision()); } /** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */ public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) { final DanceSchool staged = snapshot.restore(); + final long revision = replaceId == null ? 0 : school(replaceId).getRevision(); final ImportPreview preview = preview(snapshot); if (!Objects.equals(preview.existingId(), replaceId)) { throw new ManagementException( @@ -347,6 +371,7 @@ public class SchoolManagementService { final List rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements); final List rebuiltShows = rebuildShows(importedShows, replacements); final DanceSchool imported = DanceSchool.builder() + .revision(revision) .key(staged.getKey()) .name(staged.getName()) .dancers(new LinkedHashSet<>(importedDancers)) diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolTransactions.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolTransactions.java new file mode 100644 index 0000000..99dc85d --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolTransactions.java @@ -0,0 +1,118 @@ +package nl.agilecc.agiledancers.service; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.concurrent.Callable; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.repository.Database; +import nl.agilecc.agiledancers.repository.RevisionConflictException; +import nl.agilecc.agiledancers.repository.SchoolReader; +import nl.agilecc.agiledancers.repository.SchoolWriter; +import nl.agilecc.agiledancers.repository.StorageException; +import nl.agilecc.agiledancers.service.model.SchoolChange; + +/** Serializes short domain units of work; solver computation never holds this lock or a database connection. */ +@Singleton +public class SchoolTransactions { + private final SchoolManagementService schools; + private final Database database; + private final SchoolReader reader; + private final SchoolWriter writer; + + @Inject + public SchoolTransactions( + final SchoolManagementService schools, + final Database database, + final SchoolReader reader, + final SchoolWriter writer) { + this.schools = schools; + this.database = database; + this.reader = reader; + this.writer = writer; + } + + /** An expected revision is mandatory for existing-school writes; null school identifies a new-school operation. */ + public Object request(final Long schoolId, final Long revision, final boolean mutation, final Callable work) + throws Exception { + return request(new SchoolChange(schoolId, revision, mutation, mutation), work); + } + + public Object request(final SchoolChange change, final Callable work) throws Exception { + synchronized (schools) { + try { + return database.transaction(() -> { + final List before = reader.load(); + schools.load(before, database::nextId); + if (change.mutation() && change.schoolId() != null) { + check(change); + schools.school(change.schoolId()) + .setRevision(change.revision() + (change.changesInputs() ? 1 : 0)); + } + final Object result = work.call(); + if (change.changesInputs()) { + store(before, change.schoolId()); + } + return result; + }); + } catch (final RevisionConflictException failure) { + throw new ManagementException(ManagementExceptionKind.CONFLICT, failure.getMessage()); + } catch (final StorageException failure) { + throw new ManagementException(ManagementExceptionKind.STORAGE_UNAVAILABLE, failure.getMessage()); + } + } + } + + private void check(final SchoolChange change) { + final long schoolId = change.schoolId(); + final Long expected = change.revision(); + final long actual = schools.school(schoolId).getRevision(); + if (expected == null || actual != expected) { + throw new ManagementException( + ManagementExceptionKind.CONFLICT, + "Deze school is gewijzigd of de schoolversie ontbreekt. Herlaad de gegevens en probeer opnieuw."); + } + writer.checkRevision(schoolId, expected); + } + + private void store(final List before, final Long schoolId) { + final List after = schools.schools(); + if (schoolId != null) { + final DanceSchool retained = after.stream() + .filter((final DanceSchool school) -> school.getId().equals(schoolId)) + .findFirst() + .orElse(null); + if (retained == null) { + writer.delete(schoolId); + } else { + writer.save(retained); + } + } + final List known = before.stream().map(DanceSchool::getId).toList(); + after.stream() + .filter((final DanceSchool school) -> !known.contains(school.getId())) + .forEach(writer::save); + } + + /** Refreshes current domain state and commits a worker's atomic result/status operation. */ + public T background(final Callable work) { + synchronized (schools) { + try { + return database.transaction(() -> { + schools.load(reader.load(), database::nextId); + return work.call(); + }); + } catch (final RuntimeException failure) { + throw failure; + } catch (final Exception failure) { + throw new IllegalStateException(failure); + } + } + } + + public void saveResult(final long schoolId) { + final DanceSchool school = schools.school(schoolId); + school.setRevision(school.getRevision() + 1); + writer.save(school); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java index 915cb69..abcc8ce 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java @@ -1,3 +1,4 @@ package nl.agilecc.agiledancers.service.model; -public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {} +public record ImportPreview( + String name, int dancers, int groups, int shows, Long existingId, String existingName, Long existingRevision) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolChange.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolChange.java new file mode 100644 index 0000000..1a5c847 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolChange.java @@ -0,0 +1,4 @@ +package nl.agilecc.agiledancers.service.model; + +/** Run acceptance checks the loaded revision but does not itself change scheduling inputs. */ +public record SchoolChange(Long schoolId, Long revision, boolean mutation, boolean changesInputs) {} diff --git a/agiledancers-be/src/main/resources/application.properties b/agiledancers-be/src/main/resources/application.properties index 43d28ef..ffb7968 100644 --- a/agiledancers-be/src/main/resources/application.properties +++ b/agiledancers-be/src/main/resources/application.properties @@ -1,7 +1,7 @@ quarkus.application.name=agiledancers quarkus.http.host=0.0.0.0 -# Scheduling is synchronous with a 30-minute solver limit. +# Keep idle SSE connections alive alongside the 30-minute solver limit. quarkus.http.idle-timeout=31M quarkus.jackson.fail-on-unknown-properties=true @@ -9,3 +9,17 @@ quarkus.jackson.fail-on-unknown-properties=true %test.quarkus.jacoco.data-file=target/jacoco.exec %test.quarkus.jacoco.reuse-data-file=true %test.quarkus.jacoco.report=false + +# Production requires an explicitly configured database; initialization failures prevent startup. +quarkus.datasource.db-kind=postgresql +%prod.quarkus.datasource.jdbc.url=${DATABASE_URL} +%prod.quarkus.datasource.username=${DATABASE_USER} +%prod.quarkus.datasource.password=${DATABASE_PASSWORD} +quarkus.datasource.jdbc.acquisition-timeout=5S +quarkus.flyway.migrate-at-start=true +quarkus.datasource.devservices.image-name=postgres:17.6 + +# Flyway owns DDL; Hibernate validates its JPA mappings against the migrated schema. +quarkus.hibernate-orm.schema-management.strategy=validate +quarkus.hibernate-orm.request-scoped.enabled=false +quarkus.datasource.jdbc.transaction-isolation-level=repeatable-read diff --git a/agiledancers-be/src/main/resources/db/migration/V1__persistent_schools.sql b/agiledancers-be/src/main/resources/db/migration/V1__persistent_schools.sql new file mode 100644 index 0000000..0420760 --- /dev/null +++ b/agiledancers-be/src/main/resources/db/migration/V1__persistent_schools.sql @@ -0,0 +1,71 @@ +CREATE SEQUENCE entity_id START 1; +CREATE TABLE school ( + id bigint PRIMARY KEY, school_key uuid NOT NULL UNIQUE, name text NOT NULL, + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0) +); +CREATE TABLE dancer ( + school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE, + id bigint NOT NULL, name text NOT NULL, birth_date date NOT NULL, + PRIMARY KEY (school_id, id) +); +CREATE TABLE coach ( + school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE, + id bigint NOT NULL, name text NOT NULL, dancer_id bigint, + PRIMARY KEY (school_id, id), + FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE dance_group ( + school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE, + id bigint NOT NULL, category text NOT NULL, group_type text NOT NULL, + start_age integer NOT NULL CHECK (start_age >= 0), end_age integer NOT NULL CHECK (end_age >= start_age), + coach_id bigint, PRIMARY KEY (school_id, id), + FOREIGN KEY (school_id, coach_id) REFERENCES coach DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE group_style ( + school_id bigint NOT NULL, group_id bigint NOT NULL, style text NOT NULL, + PRIMARY KEY (school_id, group_id, style), + FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE +); +CREATE TABLE group_dancer ( + school_id bigint NOT NULL, group_id bigint NOT NULL, dancer_id bigint NOT NULL, + PRIMARY KEY (school_id, group_id, dancer_id), + FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE, + FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE show ( + school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE, + id bigint NOT NULL, name text NOT NULL, preferred_day_parts integer NOT NULL CHECK (preferred_day_parts BETWEEN 1 AND 6), + finalized boolean NOT NULL, PRIMARY KEY (school_id, id) +); +CREATE TABLE show_appearance ( + school_id bigint NOT NULL, show_id bigint NOT NULL, group_type text NOT NULL, appearances integer NOT NULL CHECK (appearances >= 0), + PRIMARY KEY (school_id, show_id, group_type), + FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE +); +CREATE TABLE day_part ( + school_id bigint NOT NULL, show_id bigint NOT NULL, position integer NOT NULL CHECK (position >= 0), + day date NOT NULL, part_type text NOT NULL, + PRIMARY KEY (school_id, show_id, position), UNIQUE (school_id, show_id, day, part_type), + FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE +); +CREATE TABLE assignment ( + school_id bigint NOT NULL, show_id bigint NOT NULL, part_position integer NOT NULL, + position integer NOT NULL CHECK (position >= 0), group_id bigint NOT NULL, + PRIMARY KEY (school_id, show_id, part_position, position), + FOREIGN KEY (school_id, show_id, part_position) REFERENCES day_part ON DELETE CASCADE, + FOREIGN KEY (school_id, group_id) REFERENCES dance_group DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE scheduling_run ( + school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE, + show_id bigint NOT NULL, run_id uuid NOT NULL UNIQUE, show_name text NOT NULL, + started_at timestamptz NOT NULL, finished_at timestamptz, + state text NOT NULL CHECK (state IN ('RUNNING','OPTIMAL','VALID','STALE','INVALID_INPUT','INFEASIBLE','NO_SOLUTION','SOLVER_FAILED','INTERRUPTED')), + phase text NOT NULL CHECK (phase IN ('PREPARING','ATTENDANCE','SPREAD','VARIETY')), + feasible boolean NOT NULL, message text NOT NULL, + attendance_excess bigint, performance_spread integer, variety_penalty bigint, + PRIMARY KEY (school_id, show_id), + FOREIGN KEY (school_id, show_id) REFERENCES show DEFERRABLE INITIALLY DEFERRED, + CHECK ((state = 'RUNNING') = (finished_at IS NULL)), + CHECK ((attendance_excess IS NULL AND performance_spread IS NULL AND variety_penalty IS NULL) + OR (attendance_excess IS NOT NULL AND performance_spread IS NOT NULL AND variety_penalty IS NOT NULL)) +); diff --git a/agiledancers-be/src/main/resources/db/migration/V2__jpa_aggregate_versioning.sql b/agiledancers-be/src/main/resources/db/migration/V2__jpa_aggregate_versioning.sql new file mode 100644 index 0000000..8618408 --- /dev/null +++ b/agiledancers-be/src/main/resources/db/migration/V2__jpa_aggregate_versioning.sql @@ -0,0 +1,15 @@ +-- Child-only edits must dirty the school entity so @Version advances exactly once. +ALTER TABLE school ADD COLUMN change_stamp bigint NOT NULL DEFAULT 0; + +-- Keep existing identifiers and start above every value already allocated by V1. +-- JPA allocates identifiers under a row lock, including when a transaction creates a whole import graph. +CREATE TABLE entity_identifiers ( + id integer PRIMARY KEY CHECK (id = 1), + next_value bigint NOT NULL CHECK (next_value > 0) +); +INSERT INTO entity_identifiers (id, next_value) VALUES (1, nextval('entity_id')); + +-- Hibernate updates retained positions in place; date/type swaps must be checked on the final graph. +ALTER TABLE day_part DROP CONSTRAINT day_part_school_id_show_id_day_part_type_key; +ALTER TABLE day_part ADD CONSTRAINT day_part_date_type_unique + UNIQUE (school_id, show_id, day, part_type) DEFERRABLE INITIALLY DEFERRED; diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/DomainViewMapperTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/DomainViewMapperTest.java new file mode 100644 index 0000000..86f0642 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/DomainViewMapperTest.java @@ -0,0 +1,119 @@ +package nl.agilecc.agiledancers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +import nl.agilecc.agiledancers.rest.apimodel.GroupView; +import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.rest.apimodel.ShowView; +import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class DomainViewMapperTest { + private @Inject DomainViewMapper views; + + public @Test void relationshipsBecomeOrderedIdsAndUnlinkedCoachesRetainNull() { + final DanceSchool school = + DanceSchool.builder().name("School").revision(4).build(); + school.setId(7L); + final Dancer first = dancer(9L); + final Dancer second = dancer(3L); + school.addDancer(first); + school.addDancer(second); + final SchoolView mapped = views.school(school); + assertEquals(List.of(9L, 3L), mapped.dancerIds()); + assertEquals(4, mapped.revision()); + final Coach coach = Coach.builder().name("Coach").dancer(first).build(); + coach.setId(2L); + assertEquals(9L, views.coach(coach).dancerId()); + coach.setDancer(null); + assertNull(views.coach(coach).dancerId()); + school.removeDancer(first); + assertEquals(List.of(9L, 3L), mapped.dancerIds()); + assertThrows( + UnsupportedOperationException.class, () -> mapped.dancerIds().clear()); + } + + public @Test void nestedViewsKeepAssignmentOrderAndExplicitValidationWithoutSharingCollections() { + final DanceGroup first = DanceGroup.builder().category("First").build(); + first.setId(8L); + final DanceGroup second = DanceGroup.builder().category("Second").build(); + second.setId(2L); + final List assigned = new ArrayList<>(List.of(first, second)); + final DayPart part = DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(assigned) + .build(); + final List violations = new ArrayList<>(List.of("Draft validation")); + final Show show = Show.builder() + .name("Show") + .dayParts(new ArrayList<>(List.of(part))) + .appearances(Map.of()) + .build(); + show.setId(1L); + final ShowView mapped = views.show(show, violations); + assertEquals(1, mapped.preferredDayParts()); + assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds()); + assigned.clear(); + violations.clear(); + show.getDayParts().clear(); + assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds()); + assertEquals(List.of("Draft validation"), mapped.violations()); + assertThrows( + UnsupportedOperationException.class, + () -> mapped.dayParts().getFirst().danceGroupIds().clear()); + } + + public @Test void preferredDefaultsUseTheExistingRuleAndStylesAreSnapshotValues() { + final DayPart part = DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build(); + final Show show = Show.builder() + .dayParts(List.of(part, part)) + .appearances(Map.of()) + .build(); + show.setId(1L); + assertEquals(2, views.show(show, List.of()).preferredDayParts()); + show.setPreferredDayParts(1); + assertEquals(1, views.show(show, List.of()).preferredDayParts()); + final DanceGroup group = DanceGroup.builder().category("Group").build(); + group.setId(1L); + group.addStyle(DanceGroupStyle.JAZZ); + final Dancer dancer = dancer(4L); + group.addDancer(dancer); + final GroupView mapped = views.group(group); + group.replaceStyles(Set.of(DanceGroupStyle.MODERN)); + group.removeDancer(dancer); + assertEquals(Set.of(DanceGroupStyle.JAZZ), mapped.styles()); + assertEquals(List.of(4L), mapped.dancerIds()); + } + + private static Dancer dancer(final long id) { + final Dancer dancer = Dancer.builder() + .name("Dancer") + .birthDate(LocalDate.of(2000, 1, 1)) + .build(); + dancer.setId(id); + return dancer; + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/PersistenceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/PersistenceTest.java new file mode 100644 index 0000000..954b59a --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/PersistenceTest.java @@ -0,0 +1,386 @@ +package nl.agilecc.agiledancers; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; +import nl.agilecc.agiledancers.model.scheduling.SchedulingState; +import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +import nl.agilecc.agiledancers.repository.Database; +import nl.agilecc.agiledancers.repository.RunRepository; +import nl.agilecc.agiledancers.repository.SchoolReader; +import nl.agilecc.agiledancers.repository.SchoolWriter; +import nl.agilecc.agiledancers.repository.StorageException; +import nl.agilecc.agiledancers.service.SchoolManagementService; +import nl.agilecc.agiledancers.service.SchoolTransactions; +import nl.agilecc.agiledancers.service.model.GroupDetails; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class PersistenceTest { + private @Inject SchoolTransactions transactions; + private @Inject SchoolManagementService schools; + private @Inject Database database; + private @Inject SchoolReader reader; + private @Inject SchoolWriter writer; + private @Inject RunRepository runs; + private @Inject jakarta.persistence.EntityManager entities; + + public @Test void simultaneousEditsRequireTheLoadedRevision() throws Exception { + final long id = create(); + final String path = "/api/dance-schools/" + id; + try (final java.util.concurrent.ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + final java.util.concurrent.Future first = executor.submit(() -> rename(path, "First", 0)); + final java.util.concurrent.Future second = executor.submit(() -> rename(path, "Second", 0)); + final List statuses = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + assertTrue(statuses.containsAll(List.of(200, 409))); + given().contentType("application/json") + .body("{\"name\":\"Missing revision\"}") + .put(path) + .then() + .statusCode(409); + given().get(path + "/data").then().statusCode(200); + assertEquals(1, given().get(path).jsonPath().getInt("revision")); + } finally { + delete(id); + } + } + + public @Test void detachedRelationalGraphRetainsIdsReferencesDraftAndFinalState() throws Exception { + final long id = create(); + try { + final Show show = fixture(id); + transactions.background(() -> { + final DanceSchool loaded = schools.school(id); + assertEquals(1, loaded.getDancers().size()); + assertEquals( + Set.of(DanceGroupStyle.JAZZ), + loaded.getDanceGroups().iterator().next().getStyles()); + assertEquals(show.getId(), loaded.getShows().iterator().next().getId()); + assertTrue(loaded.getShows().iterator().next().isFinalized()); + assertEquals( + loaded.getDanceGroups().iterator().next(), + loaded.getShows() + .iterator() + .next() + .getDayParts() + .getFirst() + .getDanceGroups() + .getFirst()); + return null; + }); + given().get("/api/dance-schools/" + id + "/export").then().statusCode(200); + } finally { + delete(id); + } + } + + public @Test void deferredConstraintFailureRollsBackDomainAndCompletedStatusTogether() throws Exception { + final long id = create(); + try { + final Show show = fixture(id); + final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName()); + database.transaction(() -> { + runs.save(run); + return null; + }); + final java.util.concurrent.atomic.AtomicBoolean published = new java.util.concurrent.atomic.AtomicBoolean(); + assertThrows(StorageException.class, () -> damageResult(id, show, run, published)); + assertFalse(published.get()); + database.transaction(() -> { + assertEquals( + "Persistent school", + reader.load().stream() + .filter((final DanceSchool school) -> school.getId() == id) + .findFirst() + .orElseThrow() + .getName()); + assertEquals( + 1, + entities.find( + nl.agilecc.agiledancers.repository.entity.DayPartEntity.class, + new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0)) + .getGroupIds() + .size()); + assertEquals( + SchedulingState.RUNNING, + runs.load().stream() + .filter((final SchedulingRun item) -> + item.runId().equals(run.runId())) + .findFirst() + .orElseThrow() + .state()); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void recoveryRetainsRunIdentityProgressAndExistingAssignments() throws Exception { + final long id = create(); + try { + final Show show = fixture(id); + final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName()) + .progressed(new SchedulingProgress(SchedulingPhase.SPREAD, true)); + database.transaction(() -> { + runs.save(run); + return null; + }); + database.transaction(() -> { + runs.recover(); + return null; + }); + database.transaction(() -> { + final SchedulingRun recovered = runs.load().stream() + .filter((final SchedulingRun item) -> item.runId().equals(run.runId())) + .findFirst() + .orElseThrow(); + assertEquals(SchedulingState.INTERRUPTED, recovered.state()); + assertEquals(run.progress(), recovered.progress()); + assertTrue(recovered.finishedAt() != null); + assertTrue(recovered.message().contains("behouden")); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void aFailedReplacementImportRetainsThePreviousGraphAndRevision() throws Exception { + final long id = create(); + try { + fixture(id); + final nl.agilecc.agiledancers.service.model.SchoolSnapshot before = + transactions.background(() -> schools.exportSchool(id)); + assertThrows( + nl.agilecc.agiledancers.service.ManagementException.class, + () -> transactions.request(id, 1L, true, () -> { + final DanceSchool imported = schools.importSchool(before, id); + imported.getDancers().iterator().next().setBirthDate(null); + return imported; + })); + transactions.background(() -> { + assertEquals(before, schools.exportSchool(id)); + assertEquals(1, schools.school(id).getRevision()); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void changedSchoolInputsRejectAnOlderSolverSnapshot() throws Exception { + final long id = create(); + try { + final Show show = fixture(id); + final nl.agilecc.agiledancers.model.scheduling.SchedulingInput input = + transactions.background(() -> schools.beginScheduling(id, show.getId())); + transactions.request(id, 1L, true, () -> schools.updateSchool(id, "Changed")); + final nl.agilecc.agiledancers.service.ManagementException failure = assertThrows( + nl.agilecc.agiledancers.service.ManagementException.class, + () -> transactions.background(() -> { + schools.finishScheduling( + id, + input, + new nl.agilecc.agiledancers.model.scheduling.SchedulingResult( + true, List.of(List.of()))); + return null; + })); + assertEquals(nl.agilecc.agiledancers.service.ManagementExceptionKind.CONFLICT, failure.getKind()); + transactions.background(() -> { + assertEquals( + 1, + schools.show(id, show.getId()) + .getDayParts() + .getFirst() + .getDanceGroups() + .size()); + schools.endScheduling(show.getId()); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void hibernateVersionRejectsDetachedStaleEntities() throws Exception { + final long id = create(); + try { + final nl.agilecc.agiledancers.repository.entity.SchoolEntity stale = database.transaction( + () -> entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)); + database.transaction(() -> { + entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id) + .setName("Confirmed"); + return null; + }); + stale.setName("Stale"); + assertThrows( + nl.agilecc.agiledancers.repository.RevisionConflictException.class, + () -> database.transaction(() -> entities.merge(stale))); + transactions.background(() -> { + assertEquals("Confirmed", schools.school(id).getName()); + assertEquals(1, schools.school(id).getRevision()); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void childOnlyEditsAdvanceTheAggregateVersionOnce() throws Exception { + final long id = create(); + try { + fixture(id); + transactions.request(id, 1L, true, () -> { + final long dancerId = schools.dancers(id).getFirst().getId(); + return schools.updateDancer(id, dancerId, "Updated dancer", LocalDate.of(2000, 1, 1)); + }); + transactions.background(() -> { + assertEquals(2, schools.school(id).getRevision()); + assertEquals("Updated dancer", schools.dancers(id).getFirst().getName()); + return null; + }); + } finally { + delete(id); + } + } + + public @Test void hibernateRetainsAssignmentsWhenDayPartsAreReordered() throws Exception { + final long id = create(); + try { + final Show show = fixture(id); + final DayPart added = DayPart.builder() + .date(LocalDate.of(2027, 1, 2)) + .type(DayPartType.EVENING) + .danceGroups(List.of()) + .build(); + transactions.request( + id, + 1L, + true, + () -> schools.updateShow( + id, + show.getId(), + "Two parts", + List.of(show.getDayParts().getFirst(), added), + null)); + transactions.request(id, 2L, true, () -> { + final Show current = schools.show(id, show.getId()); + return schools.updateShow( + id, + show.getId(), + "Swapped", + List.of( + current.getDayParts().getLast(), + current.getDayParts().getFirst()), + null); + }); + transactions.background(() -> { + final Show restored = schools.show(id, show.getId()); + assertEquals(added.getDate(), restored.getDayParts().getFirst().getDate()); + assertEquals( + 1, restored.getDayParts().getLast().getDanceGroups().size()); + assertEquals(3, schools.school(id).getRevision()); + return null; + }); + } finally { + delete(id); + } + } + + private void damageResult( + final long id, + final Show show, + final SchedulingRun run, + final java.util.concurrent.atomic.AtomicBoolean published) + throws Exception { + database.transaction(() -> { + entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id) + .setName("Uncommitted"); + entities.find( + nl.agilecc.agiledancers.repository.entity.DayPartEntity.class, + new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0)) + .getGroupIds() + .clear(); + runs.save(run.completed(SchedulingState.OPTIMAL, "Uncommitted", new SchedulingSummary(0, 0, 0))); + database.afterCommit(() -> published.set(true)); + // This dancer FK is deferred: work returns normally, and commit itself must fail. + entities.find( + nl.agilecc.agiledancers.repository.entity.GroupEntity.class, + new nl.agilecc.agiledancers.repository.entity.SchoolMemberId( + id, + show.getDayParts() + .getFirst() + .getDanceGroups() + .getFirst() + .getId())) + .getDancerIds() + .add(999999L); + return null; + }); + } + + private long create() throws Exception { + return (Long) transactions.request( + null, + null, + true, + () -> schools.createSchool("Persistent school").getId()); + } + + private Show fixture(final long id) throws Exception { + return (Show) transactions.request(id, 0L, true, () -> { + final long dancer = + schools.createDancer(id, "Dancer", LocalDate.of(2000, 1, 1)).getId(); + final long group = schools.createGroup( + id, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of(DanceGroupStyle.JAZZ))) + .getId(); + schools.addGroupDancer(id, group, dancer); + final Show show = schools.createShow( + id, + "Saved show", + List.of(DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of(schools.group(id, group))) + .build()), + null); + show.setFinalized(true); + return show; + }); + } + + private int rename(final String path, final String name, final long revision) { + return given().header("X-School-Revision", revision) + .contentType("application/json") + .body(java.util.Map.of("name", name)) + .put(path) + .statusCode(); + } + + private void delete(final long id) { + transactions.background(() -> { + writer.delete(id); + return null; + }); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/RevisionRequests.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/RevisionRequests.java new file mode 100644 index 0000000..ff39ad6 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/RevisionRequests.java @@ -0,0 +1,50 @@ +package nl.agilecc.agiledancers; + +import io.restassured.RestAssured; +import io.restassured.response.Response; +import io.restassured.specification.FilterableRequestSpecification; +import io.restassured.specification.RequestSpecification; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Existing endpoint scenarios use fresh revisions; concurrency tests deliberately send explicit stale revisions. */ +public class RevisionRequests { + private static final Pattern SCHOOL = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)"); + private static final Pattern REPLACEMENT = Pattern.compile("[?&]replaceId=(\\d+)"); + + public static RequestSpecification given() { + return RestAssured.given() + .filter( + (final FilterableRequestSpecification request, + final io.restassured.specification.FilterableResponseSpecification response, + final io.restassured.filter.FilterContext context) -> { + addRevision(request); + return context.next(request, response); + }); + } + + private static void addRevision(final FilterableRequestSpecification request) { + final String id = schoolId(request); + if (!request.getMethod().equals("GET") + && id != null + && !request.getHeaders().hasHeaderWithName("X-School-Revision")) { + final Response school = RestAssured.given().get("/api/dance-schools/" + id); + if (school.statusCode() == 200) { + request.header("X-School-Revision", school.jsonPath().getLong("revision")); + } + } + } + + private static String schoolId(final FilterableRequestSpecification request) { + final Matcher school = SCHOOL.matcher(request.getURI()); + if (school.find()) { + return school.group(1); + } + final Matcher replacement = REPLACEMENT.matcher(request.getURI()); + if (replacement.find()) { + return replacement.group(1); + } + final Object parameter = request.getQueryParams().get("replaceId"); + return parameter == null ? null : parameter.toString(); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java index 0001090..cf05ece 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java @@ -1,6 +1,6 @@ package nl.agilecc.agiledancers; -import static io.restassured.RestAssured.given; +import static nl.agilecc.agiledancers.RevisionRequests.given; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java index 40a62e6..8c8fbae 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java @@ -1,6 +1,6 @@ package nl.agilecc.agiledancers; -import static io.restassured.RestAssured.given; +import static nl.agilecc.agiledancers.RevisionRequests.given; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -36,12 +36,14 @@ import org.junit.jupiter.api.Test; class SchedulingEventsTest { private @Inject SchoolManagementService schools; private @Inject ObjectMapper json; + private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions; private @TestHTTPResource URI base; public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception { - final long school = schools.createSchool("SSE school").getId(); + final long school = (Long) transactions.request( + null, null, true, () -> schools.createSchool("SSE school").getId()); try { - final Show show = show(school); + final Show show = (Show) transactions.request(school, 0L, true, () -> show(school)); final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule"; given().get(path).then().statusCode(204); try (final HttpClient client = HttpClient.newHttpClient(); @@ -64,7 +66,7 @@ class SchedulingEventsTest { } assertReconnectContains(show.getId()); } finally { - schools.deleteSchool(school); + given().delete("/api/dance-schools/" + school); } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java index 47f53b5..2bb471d 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -1,6 +1,6 @@ package nl.agilecc.agiledancers; -import static io.restassured.RestAssured.given; +import static nl.agilecc.agiledancers.RevisionRequests.given; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java index 123eea5..8796a55 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java @@ -1,6 +1,6 @@ package nl.agilecc.agiledancers; -import static io.restassured.RestAssured.given; +import static nl.agilecc.agiledancers.RevisionRequests.given; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.emptyOrNullString; import static org.hamcrest.Matchers.equalTo; diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java index 0e7dfbc..a4d9ee1 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java @@ -19,11 +19,11 @@ import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; +import nl.agilecc.agiledancers.model.scheduling.SchedulingState; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DayPartType; import nl.agilecc.agiledancers.service.model.GroupDetails; -import nl.agilecc.agiledancers.service.model.SchedulingRun; -import nl.agilecc.agiledancers.service.model.SchedulingState; import nl.agilecc.agiledancers.service.port.SchedulingEngine; import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; import org.junit.jupiter.api.Test; diff --git a/agiledancers-fe/e2e/school-management.spec.ts b/agiledancers-fe/e2e/school-management.spec.ts index e32d0a3..9e40905 100644 --- a/agiledancers-fe/e2e/school-management.spec.ts +++ b/agiledancers-fe/e2e/school-management.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from '@playwright/test'; +import { test, expect, Page, APIRequestContext } from '@playwright/test'; import type { School } from '../src/app/core/api-models'; const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080'; @@ -6,11 +6,23 @@ let schoolToDelete: number | undefined; test.afterEach(async ({ request }) => { if (schoolToDelete !== undefined) { - await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`); + await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`, { + headers: await revision(request, schoolToDelete), + }); schoolToDelete = undefined; } }); +async function revision( + request: APIRequestContext, + schoolId: number, +): Promise> { + const school = (await ( + await request.get(`${backend}/api/dance-schools/${schoolId}`) + ).json()) as School; + return { 'X-School-Revision': String(school.revision) }; +} + async function tab(page: Page, label: string) { await page .getByRole('navigation', { name: 'Dansschool onderdelen' }) @@ -113,6 +125,7 @@ test('manages a school, shared memberships and shows through the real REST API', const dancerResponse = await request.post( `${backend}/api/dance-schools/${schoolToDelete}/dancers`, { + headers: await revision(request, schoolToDelete), data: { name, birthDate: '1990-01-01' }, }, ); @@ -120,14 +133,17 @@ test('manages a school, shared memberships and shows through the real REST API', const groupResponse = await request.post( `${backend}/api/dance-schools/${schoolToDelete}/dance-groups`, { + headers: await revision(request, schoolToDelete), data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] }, }, ); const group = (await groupResponse.json()) as { id: number }; await request.put( `${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`, + { headers: await revision(request, schoolToDelete) }, ); } + await page.reload(); await tab(page, 'Shows'); await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); @@ -214,12 +230,14 @@ test('links a coach and finalizes performances spread across multiple dates', as schoolToDelete = school.id; const base = `${backend}/api/dance-schools/${school.id}`; const dancerResponse = await request.post(`${base}/dancers`, { + headers: await revision(request, school.id), data: { name: 'Dancing coach', birthDate: '1990-01-01' }, }); const dancer = (await dancerResponse.json()) as { id: number }; const groupIds: number[] = []; for (const name of ['Preschool', 'Adults']) { const response = await request.post(`${base}/dance-groups`, { + headers: await revision(request, school.id), data: { category: name, type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR', @@ -230,9 +248,12 @@ test('links a coach and finalizes performances spread across multiple dates', as }); const group = (await response.json()) as { id: number }; groupIds.push(group.id); - await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`); + await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`, { + headers: await revision(request, school.id), + }); } const showResponse = await request.post(`${base}/shows`, { + headers: await revision(request, school.id), data: { name: 'Coach across dates', preferredDayParts: 1, @@ -244,6 +265,7 @@ test('links a coach and finalizes performances spread across multiple dates', as }); const show = (await showResponse.json()) as { id: number }; await request.put(`${base}/shows/${show.id}/assignments`, { + headers: await revision(request, school.id), data: { assignments: groupIds.map((id) => [id]) }, }); await page.goto(`/schools/${school.id}/shows`); @@ -271,3 +293,39 @@ test('shows a useful connection error and recovers on retry', async ({ page }) = await page.getByRole('button', { name: 'Opnieuw proberen' }).click(); await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible(); }); + +test('rejects a stale editor in another screen and allows reload and retry', async ({ + page, + request, +}) => { + const response = await request.post(`${backend}/api/dance-schools`, { + data: { name: `Concurrent school ${Date.now()}` }, + }); + const school = (await response.json()) as School; + schoolToDelete = school.id; + const other = await page.context().newPage(); + try { + for (const screen of [page, other]) { + await screen.goto(`/schools/${school.id}/overview`); + await screen.getByRole('button', { name: 'School bewerken' }).click(); + } + await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('First confirmed edit'); + await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Stale edit'); + await save(page); + await other.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click(); + await expect(other.getByRole('dialog').getByRole('alert')).toContainText('Herlaad'); + await expect(other.getByRole('dialog').getByLabel('Naam', { exact: true })).toHaveValue( + 'Stale edit', + ); + await other.reload(); + await expect( + other.getByRole('heading', { name: 'First confirmed edit', exact: true }), + ).toBeVisible(); + await other.getByRole('button', { name: 'School bewerken' }).click(); + await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Reloaded edit'); + await save(other); + await expect(other.getByRole('heading', { name: 'Reloaded edit', exact: true })).toBeVisible(); + } finally { + await other.close(); + } +}); diff --git a/agiledancers-fe/src/app/core/api-models.ts b/agiledancers-fe/src/app/core/api-models.ts index 6fa6a02..d586f5c 100644 --- a/agiledancers-fe/src/app/core/api-models.ts +++ b/agiledancers-fe/src/app/core/api-models.ts @@ -1,5 +1,6 @@ export interface School { id: number; + revision: number; name: string; dancerIds: number[]; danceGroupIds: number[]; diff --git a/agiledancers-fe/src/app/core/scheduling-status.ts b/agiledancers-fe/src/app/core/scheduling-status.ts index 248276d..3ef98c4 100644 --- a/agiledancers-fe/src/app/core/scheduling-status.ts +++ b/agiledancers-fe/src/app/core/scheduling-status.ts @@ -2,6 +2,7 @@ import { DestroyRef, inject, Injectable, signal } from '@angular/core'; export type SchedulingState = | 'RUNNING' + | 'INTERRUPTED' | 'OPTIMAL' | 'VALID' | 'STALE' diff --git a/agiledancers-fe/src/app/core/school-api.spec.ts b/agiledancers-fe/src/app/core/school-api.spec.ts index 96613ea..95c78de 100644 --- a/agiledancers-fe/src/app/core/school-api.spec.ts +++ b/agiledancers-fe/src/app/core/school-api.spec.ts @@ -18,13 +18,17 @@ describe('SchoolApi contract', () => { it('loads all resources from the same school', () => { const received = vi.fn(); api.schoolData(7).subscribe(received); - const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] }; - http.expectOne('/api/dance-schools/7').flush(school); - http.expectOne('/api/dance-schools/7/dancers').flush([]); - http.expectOne('/api/dance-schools/7/dance-groups').flush([]); - expect(received).not.toHaveBeenCalled(); - http.expectOne('/api/dance-schools/7/shows').flush([]); - http.expectOne('/api/dance-schools/7/coaches').flush([]); + const school = { + id: 7, + name: 'Studio', + revision: 0, + dancerIds: [], + danceGroupIds: [], + showIds: [], + }; + http + .expectOne('/api/dance-schools/7/data') + .flush({ school, dancers: [], groups: [], shows: [], coaches: [] }); expect(received).toHaveBeenCalledWith({ school, dancers: [], @@ -34,12 +38,32 @@ describe('SchoolApi contract', () => { }); }); + it('sends the loaded school revision without retrying a stale mutation', () => { + api.schoolData(7).subscribe(); + http + .expectOne('/api/dance-schools/7/data') + .flush({ school: { id: 7, revision: 4 }, dancers: [], groups: [], shows: [], coaches: [] }); + const failure = vi.fn(); + api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure }); + const request = http.expectOne('/api/dance-schools/7/dancers'); + expect(request.request.headers.get('X-School-Revision')).toBe('4'); + request.flush({ message: 'Herlaad de gegevens' }, { status: 409, statusText: 'Conflict' }); + expect(failure).toHaveBeenCalledOnce(); + }); + it('updates an existing entity even when its id is zero', () => { api.saveSchool({ name: 'Renamed' }, 0).subscribe(); const request = http.expectOne('/api/dance-schools/0'); expect(request.request.method).toBe('PUT'); expect(request.request.body).toEqual({ name: 'Renamed' }); - request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] }); + request.flush({ + id: 0, + name: 'Renamed', + revision: 0, + dancerIds: [], + danceGroupIds: [], + showIds: [], + }); }); it('keeps group details separate from memberships', () => { diff --git a/agiledancers-fe/src/app/core/school-api.ts b/agiledancers-fe/src/app/core/school-api.ts index 457337e..78c06de 100644 --- a/agiledancers-fe/src/app/core/school-api.ts +++ b/agiledancers-fe/src/app/core/school-api.ts @@ -1,7 +1,7 @@ import { SchedulingRun } from './scheduling-status'; import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { forkJoin, Observable } from 'rxjs'; +import { Observable, tap } from 'rxjs'; import { Coach, DanceGroup, @@ -20,64 +20,114 @@ import { export class SchoolApi { private readonly http = inject(HttpClient); private readonly base = '/api/dance-schools'; + private readonly revisions = new Map(); schools(): Observable { - return this.http.get(this.base); + return this.http.get(this.base).pipe( + tap((schools) => { + for (const school of schools) this.revisions.set(school.id, school.revision); + }), + ); } schoolData(schoolId: number): Observable { - const base = `${this.base}/${schoolId}`; - return forkJoin({ - school: this.http.get(base), - dancers: this.http.get(`${base}/dancers`), - groups: this.http.get(`${base}/dance-groups`), - shows: this.http.get(`${base}/shows`), - coaches: this.http.get(`${base}/coaches`), - }); + return this.http + .get(`${this.base}/${schoolId}/data`) + .pipe(tap((data) => this.revisions.set(schoolId, data.school.revision))); } - saveSchool(body: SchoolRequest, id?: number): Observable { + private options(schoolId: number, expected?: number): { headers: Record } { + const revision = expected ?? this.revisions.get(schoolId); + return { headers: revision === undefined ? {} : { 'X-School-Revision': String(revision) } }; + } + saveSchool(body: SchoolRequest, id?: number, revision?: number): Observable { return id === undefined ? this.http.post(this.base, body) - : this.http.put(`${this.base}/${id}`, body); + : this.http.put(`${this.base}/${id}`, body, this.options(id, revision)); } - saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable { - return this.save(`${this.base}/${schoolId}/dancers`, body, id); + saveDancer( + schoolId: number, + body: DancerRequest, + id?: number, + revision?: number, + ): Observable { + return this.save(`${this.base}/${schoolId}/dancers`, body, schoolId, id, revision); } - saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable { - return this.save(`${this.base}/${schoolId}/dance-groups`, body, id); + saveGroup( + schoolId: number, + body: GroupRequest, + id?: number, + revision?: number, + ): Observable { + return this.save(`${this.base}/${schoolId}/dance-groups`, body, schoolId, id, revision); } - saveShow(schoolId: number, body: ShowRequest, id?: number): Observable { - return this.save(`${this.base}/${schoolId}/shows`, body, id); + saveShow(schoolId: number, body: ShowRequest, id?: number, revision?: number): Observable { + return this.save(`${this.base}/${schoolId}/shows`, body, schoolId, id, revision); } schedule(schoolId: number, showId: number): Observable { - return this.http.post(`${this.base}/${schoolId}/shows/${showId}/schedule`, null); + return this.http.post( + `${this.base}/${schoolId}/shows/${showId}/schedule`, + null, + this.options(schoolId), + ); } - saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable { - return this.http.put(`${this.base}/${schoolId}/shows/${showId}/assignments`, { - assignments, - }); + saveAssignments( + schoolId: number, + showId: number, + assignments: number[][], + revision?: number, + ): Observable { + return this.http.put( + `${this.base}/${schoolId}/shows/${showId}/assignments`, + { + assignments, + }, + this.options(schoolId, revision), + ); } - finalize(schoolId: number, showId: number): Observable { - return this.http.post(`${this.base}/${schoolId}/shows/${showId}/finalize`, null); + finalize(schoolId: number, showId: number, revision?: number): Observable { + return this.http.post( + `${this.base}/${schoolId}/shows/${showId}/finalize`, + null, + this.options(schoolId, revision), + ); } linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable { - return this.save(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId); + return this.save(`${this.base}/${schoolId}/coaches`, { dancerId }, schoolId, coachId); } unlinkCoach(schoolId: number, coachId: number): Observable { - return this.http.delete(`${this.base}/${schoolId}/coaches/${coachId}/dancer`); + return this.http.delete( + `${this.base}/${schoolId}/coaches/${coachId}/dancer`, + this.options(schoolId), + ); } - delete(kind: ResourceKind, schoolId: number, id: number): Observable { + delete(kind: ResourceKind, schoolId: number, id: number, revision?: number): Observable { const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ kind ]; const url = kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`; - return this.http.delete(url); + return this.http.delete(url, this.options(schoolId, revision)); } - membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable { + membership( + schoolId: number, + groupId: number, + dancerId: number, + add: boolean, + revision?: number, + ): Observable { const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`; - return add ? this.http.put(url, null) : this.http.delete(url); + return add + ? this.http.put(url, null, this.options(schoolId, revision)) + : this.http.delete(url, this.options(schoolId, revision)); } - private save(url: string, body: unknown, id?: number): Observable { - return id === undefined ? this.http.post(url, body) : this.http.put(`${url}/${id}`, body); + private save( + url: string, + body: unknown, + schoolId: number, + id?: number, + revision?: number, + ): Observable { + return id === undefined + ? this.http.post(url, body, this.options(schoolId, revision)) + : this.http.put(`${url}/${id}`, body, this.options(schoolId, revision)); } } diff --git a/agiledancers-fe/src/app/schools/school-page.html b/agiledancers-fe/src/app/schools/school-page.html index 92c1fa8..ce88522 100644 --- a/agiledancers-fe/src/app/schools/school-page.html +++ b/agiledancers-fe/src/app/schools/school-page.html @@ -477,6 +477,7 @@ @@ -495,6 +497,7 @@ { const saved = vi.fn(); component.saved.subscribe(saved); await component.save(); - expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined); + expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined, undefined); expect(saved).toHaveBeenCalledOnce(); expect(component.busy()).toBe(false); }); + it('retains the revision from opening the editor across background refreshes', async () => { + api.saveDancer.mockReturnValue(of({ id: 3 })); + const fixture = TestBed.createComponent(EntityEditor); + fixture.componentRef.setInput('target', { + kind: 'dancer', + value: { id: 3, name: 'Sam', birthDate: '2010-01-01' }, + }); + fixture.componentRef.setInput('schoolId', 7); + fixture.componentRef.setInput('revision', 4); + const component = fixture.componentInstance; + component.ngOnInit(); + fixture.componentRef.setInput('revision', 5); + component.name = 'Sam edited'; + await component.save(); + expect(api.saveDancer).toHaveBeenCalledWith( + 7, + { name: 'Sam edited', birthDate: '2010-01-01' }, + 3, + 4, + ); + }); + it('keeps the editor open and preserves input when saving fails', async () => { api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); const component = editor({ kind: 'school' }); @@ -116,6 +138,7 @@ describe('EntityEditor', () => { 7, expect.objectContaining({ preferredDayParts: 1 }), undefined, + undefined, ); }); diff --git a/agiledancers-fe/src/app/shared/entity-editor.ts b/agiledancers-fe/src/app/shared/entity-editor.ts index 26d9168..d3795a0 100644 --- a/agiledancers-fe/src/app/shared/entity-editor.ts +++ b/agiledancers-fe/src/app/shared/entity-editor.ts @@ -174,6 +174,8 @@ import { DialogFrame } from './dialog-frame'; }) export class EntityEditor implements OnInit { readonly target = input.required(); + readonly revision = input(); + private originalRevision?: number; readonly schoolId = input(); readonly shows = input([]); readonly closed = output(); @@ -207,6 +209,7 @@ export class EntityEditor implements OnInit { return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`; } ngOnInit(): void { + this.originalRevision = this.revision(); const target = this.target(); if (!target.value) return; if (target.kind === 'group') { @@ -298,13 +301,14 @@ export class EntityEditor implements OnInit { let request: Observable; switch (target.kind) { case 'school': - request = this.api.saveSchool({ name: this.name.trim() }, id); + request = this.api.saveSchool({ name: this.name.trim() }, id, this.originalRevision); break; case 'dancer': request = this.api.saveDancer( schoolId!, { name: this.name.trim(), birthDate: this.date }, id, + this.originalRevision, ); break; case 'group': @@ -318,6 +322,7 @@ export class EntityEditor implements OnInit { styles: this.selectedStyles, }, id, + this.originalRevision, ); break; case 'show': @@ -329,6 +334,7 @@ export class EntityEditor implements OnInit { preferredDayParts: this.preferredDayParts, }, id, + this.originalRevision, ); break; } diff --git a/agiledancers-fe/src/app/shared/group-members.ts b/agiledancers-fe/src/app/shared/group-members.ts index 3ec7f7a..d0acbdc 100644 --- a/agiledancers-fe/src/app/shared/group-members.ts +++ b/agiledancers-fe/src/app/shared/group-members.ts @@ -57,6 +57,8 @@ import { DialogFrame } from './dialog-frame'; `, }) export class GroupMembers implements OnInit { + readonly revision = input(); + private originalRevision?: number; readonly schoolId = input.required(); readonly group = input.required(); readonly dancers = input.required(); @@ -70,6 +72,7 @@ export class GroupMembers implements OnInit { ); private readonly api = inject(SchoolApi); ngOnInit(): void { + this.originalRevision = this.revision(); this.memberIds.set([...this.group().dancerIds]); } async toggle(dancerId: number, event: Event): Promise { @@ -82,7 +85,10 @@ export class GroupMembers implements OnInit { this.busy.set(true); this.error.set(''); try { - await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add)); + await firstValueFrom( + this.api.membership(this.schoolId(), this.group().id, dancerId, add, this.originalRevision), + ); + if (this.originalRevision !== undefined) this.originalRevision++; this.memberIds.update((ids) => add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId), ); diff --git a/agiledancers-fe/src/app/shared/schedule-editor.spec.ts b/agiledancers-fe/src/app/shared/schedule-editor.spec.ts index aae9a02..9a330af 100644 --- a/agiledancers-fe/src/app/shared/schedule-editor.spec.ts +++ b/agiledancers-fe/src/app/shared/schedule-editor.spec.ts @@ -57,7 +57,7 @@ describe('ScheduleEditor', () => { const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] }; api.saveAssignments.mockReturnValue(of(draft)); await component.save(); - expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]); + expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]], undefined); expect(component.current()).toEqual(draft); expect(component.dirty()).toBe(false); expect(api.finalize).not.toHaveBeenCalled(); diff --git a/agiledancers-fe/src/app/shared/schedule-editor.ts b/agiledancers-fe/src/app/shared/schedule-editor.ts index 88c7f50..a27d6e9 100644 --- a/agiledancers-fe/src/app/shared/schedule-editor.ts +++ b/agiledancers-fe/src/app/shared/schedule-editor.ts @@ -113,6 +113,8 @@ import { DialogFrame } from './dialog-frame'; export class ScheduleEditor implements OnInit { readonly show = input.required(); readonly groups = input.required(); + readonly revision = input(); + private originalRevision?: number; readonly schoolId = input.required(); readonly closed = output(); readonly saved = output(); @@ -124,6 +126,7 @@ export class ScheduleEditor implements OnInit { assignments: number[][] = []; ngOnInit(): void { + this.originalRevision = this.revision(); this.current.set(this.show()); this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]); } @@ -162,9 +165,15 @@ export class ScheduleEditor implements OnInit { try { const result = await firstValueFrom( finalize - ? this.api.finalize(this.schoolId(), this.show().id) - : this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments), + ? this.api.finalize(this.schoolId(), this.show().id, this.originalRevision) + : this.api.saveAssignments( + this.schoolId(), + this.show().id, + this.assignments, + this.originalRevision, + ), ); + if (this.originalRevision !== undefined) this.originalRevision++; this.current.set(result); this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]); this.dirty.set(false); diff --git a/agiledancers-fe/src/app/shared/school-transfer.ts b/agiledancers-fe/src/app/shared/school-transfer.ts index 5ff809c..a1aa0ac 100644 --- a/agiledancers-fe/src/app/shared/school-transfer.ts +++ b/agiledancers-fe/src/app/shared/school-transfer.ts @@ -10,6 +10,7 @@ interface Preview { shows: number; existingId: number | null; existingName: string | null; + existingRevision: number | null; } @Component({ selector: 'app-school-transfer', @@ -117,7 +118,12 @@ export class SchoolTransfer { const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`; await firstValueFrom( this.http.post(`${this.base}/import${query}`, this.json, { - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(p.existingRevision === null + ? {} + : { 'X-School-Revision': String(p.existingRevision) }), + }, }), ); this.cancel(); diff --git a/ci/Pipeline.java b/ci/Pipeline.java index 6a3b7ef..ad53b66 100644 --- a/ci/Pipeline.java +++ b/ci/Pipeline.java @@ -131,6 +131,29 @@ class Pipeline { "agiledancers-fe"); Files.writeString(artifacts.resolve("containers-started"), ""); run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName); + run( + "docker", + "run", + "-d", + "--name", + runName + "-database", + "--network", + runName, + "--network-alias", + "database", + "--env", + "POSTGRES_USER=agiledancers", + "--env", + "POSTGRES_PASSWORD=ci-only", + "--env", + "POSTGRES_DB=agiledancers", + "postgres:17.6"); + for (int attempt = 0; attempt < 60; attempt++) { + if (result(false, "docker", "exec", runName + "-database", "pg_isready", "-U", "agiledancers") + .code() + == 0) break; + Thread.sleep(1000); + } run( "docker", "run", @@ -153,6 +176,12 @@ class Pipeline { root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro", "--env", "JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300", + "--env", + "DATABASE_URL=jdbc:postgresql://database:5432/agiledancers", + "--env", + "DATABASE_USER=agiledancers", + "--env", + "DATABASE_PASSWORD=ci-only", backendImage); run( "docker", @@ -328,13 +357,14 @@ class Pipeline { Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING); } if (result(false, "docker", "info").code() == 0) { - for (String role : List.of("backend", "frontend", "browser")) { + for (String role : List.of("backend", "frontend", "browser", "database")) { String container = runName + "-" + role; if (result(false, "docker", "container", "inspect", container).code() == 0) { var logs = result(false, "docker", "logs", container); Files.writeString(artifacts.resolve(role + ".log"), logs.output()); if (logs.code() != 0) failures.add(role + " logs"); - if (result(false, "docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup"); + if (result(false, "docker", "rm", "-f", "-v", container).code() != 0) + failures.add(role + " cleanup"); } } if (result(false, "docker", "network", "inspect", runName).code() == 0 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 6a58bd6..0abf693 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,6 +1,27 @@ # Images are resolved to registry digests before replacement. Never use :main here. services: + database: + image: postgres:17.6 + restart: unless-stopped + environment: + POSTGRES_DB: agiledancers + POSTGRES_USER: agiledancers + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?Required database password} + volumes: + - database-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U agiledancers -d agiledancers'] + interval: 5s + timeout: 3s + retries: 12 backend: + depends_on: + database: + condition: service_healthy + environment: + DATABASE_URL: jdbc:postgresql://database:5432/agiledancers + DATABASE_USER: agiledancers + DATABASE_PASSWORD: ${DATABASE_PASSWORD:?Required database password} image: ${BACKEND_IMAGE:?Required verified backend digest} platform: linux/amd64 restart: unless-stopped @@ -32,3 +53,7 @@ services: options: max-size: '10m' max-file: '3' + +volumes: + database-data: + name: agiledancers-production-database diff --git a/deploy/production.env.example b/deploy/production.env.example index abc8002..18aea36 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -7,3 +7,6 @@ DOCKER_BIN=/usr/local/bin/docker DOCKER_SUDO=1 # The pull-only registry login is provisioned separately using docker login. DOCKER_CONFIG=/volume1/docker/agiledancers-production/docker-auth + +# Set a long random password before the first S16 deployment. Never commit its real value. +DATABASE_PASSWORD=replace-with-a-long-random-password diff --git a/deploy/remote.sh b/deploy/remote.sh index 3f28fa5..8c8be3a 100644 --- a/deploy/remote.sh +++ b/deploy/remote.sh @@ -65,6 +65,8 @@ test -f production.env || die 'Missing production.env; follow provisioning runbo . "$root/production.env" : "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}" export DOCKER_CONFIG +# Compose expands database credentials from trusted production.env, never release metadata. +if [ -n "${DATABASE_PASSWORD:-}" ]; then export DATABASE_PASSWORD; fi docker_cmd() { if [ "${DOCKER_SUDO:-1}" = 1 ]; then sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@" @@ -170,7 +172,9 @@ case "$action" in test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window' end=$(( $(date +%s) + health_seconds )) # Always recreate the frontend too: nginx must resolve the new backend IP. - compose up -d --no-build --pull never --force-recreate --timeout 20 + # Recreate application containers only; the database and volume survive releases. + compose up -d --no-build --pull missing --timeout 20 database + compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend while ! health; do test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out' sleep 1 diff --git a/docs/persistent-storage.md b/docs/persistent-storage.md new file mode 100644 index 0000000..ab27c57 --- /dev/null +++ b/docs/persistent-storage.md @@ -0,0 +1,135 @@ +# PostgreSQL storage (S16) + +The backend requires PostgreSQL 17. Flyway applies the versioned SQL under +`agiledancers-be/src/main/resources/db/migration` before startup recovery and +readiness. Never edit an already deployed migration; add the next version instead. +The database owns domain identifiers and a monotonically increasing school +revision. Application persistence uses JPA/Hibernate ORM: mapped entities and +collections live in `repository.entity`, and repositories use `EntityManager`. +The existing domain objects remain independent of JPA. Database constraints enforce +school ownership of every relationship. + +Production uses `deploy/compose.yaml`: PostgreSQL has no published port and keeps +its data in the named volume `agiledancers-production-database`. Set a long random +`DATABASE_PASSWORD` in the mode-600 `production.env` on the NAS before the first +S16 deployment. The release helper exports it for Compose without writing it to +release metadata. The backend receives: + +- `DATABASE_URL=jdbc:postgresql://database:5432/agiledancers` +- `DATABASE_USER=agiledancers` +- `DATABASE_PASSWORD` from `production.env` + +Database health gates backend startup, and the backend readiness check includes +the datasource. Acquisition failures reject requests with `503` and a message that +the mutation was not stored. There is no volatile fallback. PostgreSQL's normal +WAL, fsync and synchronous-commit defaults must remain enabled. Application writes +are acknowledged only after commit. A network loss during acknowledgement can +leave the client uncertain; reload current data before deciding to retry. + +## Transition from the previous release + +1. Export **every school** through JSON while the old backend is still running. + Its data exists only in memory and cannot be recovered after it stops. +2. Keep those exports, configure the database password, then deploy S16. The + first startup creates an empty relational database through Flyway. +3. Import the JSON files through the existing preview and confirmation workflow. + School keys and file-local relationship semantics remain unchanged; runtime + IDs are assigned by PostgreSQL. A replacement retains the target school ID. +4. Verify schools and saved assignments after a restart before removing exports. + Import retains the existing behavior of importing schedules as drafts. + +Deployments recreate only the application containers. Never run `compose down -v` +or delete the named database volume. Rollback replaces application images, not +schema migrations; only roll back to a database-compatible release. An image from +before S16 cannot read the new database. Database/volume loss recovery and backup +operations are deferred to S17. + +## Local development and verification + +Quarkus Dev Services starts disposable PostgreSQL for development and tests when +no datasource URL is configured; Docker must be accessible. For a durable local +instance, create a PostgreSQL service with a persistent volume and pass +`QUARKUS_DATASOURCE_JDBC_URL`, `QUARKUS_DATASOURCE_USERNAME`, and +`QUARKUS_DATASOURCE_PASSWORD` to dev/test execution. Production uses the three +`DATABASE_*` variables above. Never reuse a production database for tests. + +```sh +JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 mvn -Dformat.mode=check clean verify +cd agiledancers-fe +npm test +npm run lint +npm run build +``` + +Standalone Docker builds run the complete backend tests against a reachable test +database. Supply its configuration through BuildKit secrets (not image build +arguments), for example with shell variables `TEST_DATABASE_URL`, +`TEST_DATABASE_USER` and `TEST_DATABASE_PASSWORD` already set: + +```sh +docker build --network=host \ + --secret id=database_url,env=TEST_DATABASE_URL \ + --secret id=database_user,env=TEST_DATABASE_USER \ + --secret id=database_password,env=TEST_DATABASE_PASSWORD \ + -t agiledancers . +``` + +CI runs verification against disposable PostgreSQL, packages the exact verified +output using the `prebuilt` Docker target, and starts a separate database on its +isolated browser-test network. Cleanup removes those test containers only. + +The packaged-process regression test below checks graceful and abrupt backend +termination, stable recovered run IDs through HTTP and SSE, committed results, +interrupted calculations, database outages, and failed startup/migrations. It +requires a built backend and an **isolated disposable database container**: the +test deliberately stops and restarts the named container. It does not remove its +volume. Pass matching connection options if they differ from the defaults. + +```sh +python3 tooling/persistence/restart_test.py --database-container agiledancers-s16-test +``` + +S16 targets one active backend. The existing domain monitor serializes short +snapshot/mutation/commit units of work; solver work runs outside that monitor and +outside database transactions. Results use the school revision captured at start. +Changes elsewhere in the same school may invalidate a run. Latest progress and +outcome are durable; calculations left Running at startup become Interrupted and +retain earlier saved assignments. There is no automatic resumption or run history. + + +## JPA mapping and schema upgrades + +Hibernate manages relationships, element collections, ordered assignments and +orphan removal through mapped entities. Its `@Version` on the school entity +implements optimistic concurrency. A child-only change marks the aggregate dirty, +so the school version advances exactly once without manual version-update SQL. +JTA owns the transaction boundary; run acceptance and completion are published only +after commit. Detached domain snapshots do not expose Hibernate proxies through the +API or to solver threads. + +Flyway remains the only schema writer. Hibernate runs in `validate` mode and never +creates or alters application tables. V1 is retained unchanged. V2 adds the aggregate +change stamp, a JPA-managed identifier counter seeded above the existing sequence, +and a deferred date/type uniqueness check for reordering dayparts. Existing school +keys, entity IDs, assignments and scheduling status are preserved. The counter is +locked while allocating IDs within a transaction. Rollback requires a release that +understands V2's identifier allocation; the earlier JDBC implementation is not a +compatible rollback target. + + +## API projections + +MapStruct 1.6.3 generates the REST-layer `DomainViewMapper` for domain objects to +view records. REST resources inject the generated CDI bean. Relationship collections +map to ID lists through one shared helper; validation remains a use-case result +passed explicitly to the mapper. Preferred-daypart defaults call the existing +application rule. View constructors preserve immutable snapshots. + +The mapper sets `unmappedTargetPolicy = ERROR`: every target component must have a +mapping, whether inferred from a matching property or declared explicitly. A newly +added unmatched component therefore fails compilation. The backend compiler runs +MapStruct together with Lombok and its pinned MapStruct binding. Generated classes +stay under `target/generated-sources/annotations`; they are not maintained source. + +Persistence entities and shared domain graph identity continue to use PeopleMapping, +GroupMapping and ShowMapping. MapStruct does not construct or update that graph. diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 0af1984..2eb68ab 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -4,10 +4,11 @@ De buildworkflow bouwt, test en publiceert automatisch. Productie verandert alleen via **Actions → Deploy production → Run workflow** of **Rollback production**. Start beide vanaf `main`; de workflows verschijnen na de merge. -**Tot S16 is geïmplementeerd kan iedere deployment, retry, rollback of automatisch -herstel alle applicatiegegevens wissen.** Informeer de launching partner vooraf. -Een onderbroken berekening moet opnieuw worden gestart. Een herstelde -applicatieversie herstelt geen verloren gegevens. +**Voor de eerste S16-deployment: exporteer alle scholen voordat de oude backend +stopt.** De oude release bewaart gegevens alleen in geheugen. Configureer ook +`DATABASE_PASSWORD` in `production.env`. Zie [database-inrichting en overgang](persistent-storage.md). +Nieuwe releases gebruiken een apart PostgreSQL-volume; verwijder dit volume niet. +Onderbroken berekeningen worden als Interrupted hersteld en starten niet vanzelf. ## Eenmalige DSM-inrichting @@ -285,11 +286,12 @@ nieuwe release door te drukken. ## Na S16 en lokale verificatie -S16 blokkeert S6 niet. Zodra S16 is ingevoerd, moeten database/volumes buiten de -vervangbare containers blijven en saved data zowel deployment als rollback -overleven. De gewone procedure vereist dan databasecompatibiliteit met de vorige -applicatieversie. Plan incompatibele migraties apart: imageherstel maakt geen -migratie ongedaan. Backup/restore blijven S17-werk. +De databaseservice en het volume `agiledancers-production-database` blijven buiten +de vervanging van backend en frontend. `production.env` levert het databasewachtwoord +ook bij retry en rollback; het staat niet in releasebestanden. De procedure vereist +databasecompatibiliteit met de vorige applicatieversie. Plan incompatibele migraties +apart: imageherstel maakt geen migratie ongedaan. Rollback naar een oude release +van vóór S16 kan de database niet lezen. Backup/restore blijven S17-werk. ```sh java ci/Ci.java test diff --git a/pom.xml b/pom.xml index 22ca652..b00c2a2 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,8 @@ 8.63.4 9.15.6755 + 1.6.3 + 0.2.0 1.18.48 diff --git a/specs/S16-validation.md b/specs/S16-validation.md new file mode 100644 index 0000000..12eb71e --- /dev/null +++ b/specs/S16-validation.md @@ -0,0 +1,156 @@ +# S16 implementation validation + +## Delivered behavior + +PostgreSQL owns normalized school, dancer, coach, group, membership, style, show, +daypart, appearance and ordered-assignment tables, plus the latest scheduling run. +Flyway V1 creates the schema, persistent identifier sequence, cross-school-safe +foreign keys and row constraints. School keys, runtime identifiers, relationship +identity, coach links, draft assignments and finalized state survive reload. + +Application persistence uses JPA/Hibernate ORM with mapped entities in +`repository.entity` and `EntityManager` repositories. Relationships, element +collections, daypart order and orphan removal are managed by Hibernate; domain +objects and API responses remain framework independent. The hand-written JDBC +query/update layer and row readers have been removed. + +The HTTP interceptor runs each school operation in a short repeatable-read JTA +transaction under the existing domain monitor. Reads convert managed entities into +detached domain snapshots. Hibernate's school `@Version` checks optimistic changes; +a change stamp marks child-only edits so the school revision advances exactly once. +Missing or stale caller revisions conflict before applying edits, and stale detached +JPA entities also fail. Successful graph merges flush before commit, and status +publication remains after durable commit. A single school-data endpoint gives the +editor a consistent data/revision snapshot. Open editors retain their original +revision across background refreshes; conflict responses preserve their input and +require reload before retry. Replacement imports retain the target school ID and +check the revision returned by preview. Existing JSON import/export semantics remain. + +Domain-to-view projection now uses MapStruct 1.6.3 in the REST layer. The generated +CDI `DomainViewMapper` covers schools, dancers, groups, coaches, dayparts and shows. +A shared ID conversion projects relationships to ordered ID lists; validation results +remain an explicit argument and preferred-daypart defaults delegate to the existing +application rule. View record constructors retain their defensive copies. Entity to +domain graph construction remains explicit in the persistence adapter. +`unmappedTargetPolicy = ERROR` rejects missing target fields at compilation. A compiler +probe with the actual mapper and an added unmapped SchoolView component confirms this +failure, without modifying maintained sources. MapStruct's generated implementation +is marked `lombok.Generated`, preserving the existing generated-code scope of coverage +and architecture checks; handwritten mapper helpers remain covered and checked. + +Flyway remains the schema authority; Hibernate validates rather than generates DDL. +V1 is unchanged. V2 preserves existing rows, adds the aggregate change stamp and a +JPA-managed identifier counter seeded above previously allocated IDs, and defers +date/type uniqueness until commit so Hibernate can reorder retained dayparts safely. +Identifier allocation takes a pessimistic lock on the counter row. Coach/dancer and +group/coach associations use Hibernate composite join mappings to share the owning +school key while letting Hibernate update the linked member column directly. + +Run acceptance checks the school revision and persists the run before launch or +acknowledgement. Acceptance itself does not change scheduling inputs. Background +computation holds neither a database transaction nor the domain monitor. Progress +commits before publication. Successful assignment replacement, school revision and +terminal run status commit together; publication callbacks run only after commit. +Deferred constraint failures roll back both assignments and status without +publishing success. Database failure never falls back to acknowledged memory-only +writes. Disconnected status subscribers cannot turn a committed result into a +solver failure. + +Startup recovery marks persisted Running runs Interrupted before serving status, +retains their IDs, progress and previous saved assignments, and creates no old run +guards. The frontend recognizes Interrupted and retains S12's reconnect/reload +notification behavior. Timestamps use PostgreSQL microsecond precision consistently +in live and recovered snapshots. + +## Verification + +- Maven clean verify: all 72 backend tests and 19 standards-checker regression tests + pass. The JPA integration run also verified Flyway V1 and V2 and Hibernate schema + validation against an empty database, as well as an upgrade of the populated V1 + test database. +- Backend line coverage: 1763/1827 (96.50%), above the 80% gate. +- Frontend: 32 unit tests, production build and ESLint pass (unchanged by the + MapStruct-only backend change). +- Generated-mapper CDI tests cover ordered relationship IDs, an unlinked coach, + nested assignment order, defensive copies, explicit validation messages and the + configured/default preferred-daypart rule. The compile-time unmapped-field probe + passes with the expected compiler error. +- Playwright: the three existing real REST/browser scenarios pass with PostgreSQL; + a fourth scenario verifies two screens editing the same revision, conflict + handling with retained form input, and successful reload/retry. +- PostgreSQL tests cover simultaneous edits (one succeeds, one conflicts), missing + revisions, relational graph recovery, IDs and ordered assignments, finalized + state, deferred commit failure without success publication, failed replacement + import rollback, stale scheduling inputs and interrupted-run recovery. JPA-specific + tests verify stale detached entities, exactly one aggregate version increment for + child-only edits, and reordered dayparts retaining their assignments. +- `tooling/persistence/restart_test.py` runs the packaged production backend against + an isolated persistent PostgreSQL container. It passes graceful restart and + SIGKILL interruption checks, verifies a completed saved result after every + restart, checks stable recovered IDs through HTTP and SSE, starts a new run after + interruption, rejects mutations during a database outage, and verifies startup + failure for an unreachable database and a deliberately failing Flyway migration. +- CI helper tests: 17 pass. Release and actual deployment-shell behavior tests: + 32 pass. Shell syntax and Java/frontend formatting checks pass. + +The packaged-process checks deliberately stop/restart only the named disposable +test database. Production deployment was not performed. Database provisioning and +transition instructions are in `docs/persistent-storage.md`; export every school +before stopping the old in-memory release. Production Compose retains its database +volume outside application replacement. CI supplies a separate disposable browser +database and removes its anonymous volume during cleanup. + +## Manual standards assessment + +No waivers or weaker gates were added. Architecture tests pass. Persistence entities +and their embeddable keys belong to the repository layer, while the scheduling +records remain in `model.scheduling`. Mutable classes, no-argument construction, +setters and replaceable collections are deliberate JPA lifecycle requirements; +Hibernate installs managed collection wrappers and hydrates fields. Composite key +values are assigned before persistence and are not mutated afterward. None of these +entities or lazy collections escapes into the solver or the API. Existing immutable +snapshot records still defensively copy mutable collections. + +Retained review signals in changed code: + +- Database fan-out 27 and transaction cyclomatic complexity 8: JPA/JTA lifecycle, + rollback and heuristic failures, optimistic-conflict classification, and committed + publication are handled in one transaction owner. Its two injected dependencies + remain below the review signal. +- SchoolWriter fan-out 17: one adapter builds and merges the school graph, removes + deleted-show statuses, checks JPA revisions and flushes changes. PeopleMapping, + GroupMapping and ShowMapping separate domain conversion from ORM lifecycle. +- Entity fan-out reflects explicit relationship and collection annotations: + DayPartEntity 22, GroupEntity 24, RunEntity 17, SchoolEntity 18 and ShowEntity 23. + Each maps one existing table/aggregate boundary; splitting fields merely to lower + annotation-driven fan-out would obscure the relational mappings. Entity collections + are mutable only within the persistence adapter and Hibernate session. +- SchoolTransactions fan-out 19 and request cyclomatic complexity 9: four collaborators + cover the detached domain monitor, transaction owner, loader and writer. The branch + structure distinguishes reads, run acceptance and domain mutations and maps storage + failures separately from optimistic conflicts. SchoolChange's four components + keep the revision and operation classification together. +- SchedulingRuns fan-out 31: it coordinates immutable input/progress/run/result + records, the solver port, durable lifecycle operations and subscriber callbacks; + its three injected collaborators remain below the dependency review signal. +- SchoolManagementService fan-out 38, 30 public methods, beginScheduling + cyclomatic complexity 8 and the existing five-parameter updateShow are retained: + aggregate use cases and validations remain together. Load installs detached state + and resets transient input counters under the existing monitor. +- REST resource fan-out is 18–28 (DanceSchoolResource 25): resources combine HTTP + annotations, use cases, request/view contracts and the injected projection mapper. + The consistent data endpoint assembles views inside the transaction. DomainViewMapper + fan-out 25 represents six explicit source/target projections plus the MapStruct + annotations and two small shared helpers. DomainViewMapperTest fan-out 22 exercises + the generated CDI mapper's API edge cases. SchoolView's six components include the revision; + SchoolDataView's five components represent one editor snapshot. ImportPreview's + seventh component identifies the target revision for replacement. +- PersistenceTest fan-out 47, seven injected fixtures, 335 code lines and methods of + 41/33/42 lines: these cross real ORM, graph and transaction boundaries, including + detached-entity conflicts, commit-time failure, failed import, child versioning and + reordered assignments. The fixtures remain below all blocking limits. Existing + changed REST/SSE/background fixture metrics retain their S12/S7 rationale. + +One active backend remains the supported deployment model. Global execution +capacity, distributed workers, database backup/restore and recovery after database +volume loss remain outside S16. S17 owns backup/restore. diff --git a/specs/in_review/S12_support-async-feedback-for-scheduling b/specs/implemented/S12_support-async-feedback-for-scheduling similarity index 98% rename from specs/in_review/S12_support-async-feedback-for-scheduling rename to specs/implemented/S12_support-async-feedback-for-scheduling index 97e09fc..540a5d2 100644 --- a/specs/in_review/S12_support-async-feedback-for-scheduling +++ b/specs/implemented/S12_support-async-feedback-for-scheduling @@ -1,6 +1,6 @@ # S12 — Asynchronous feedback for scheduling -Status: IN_REVIEW +Status: IMPLEMENTED Agreed after the grill-me interview; shared understanding confirmed. @@ -86,7 +86,7 @@ and what the outcome is, including when I leave and return to the show page. ## Persistence and backend restarts The maintainer explicitly transferred durable scheduling status and recovery -following backend restarts to [S16](../refined/S16_store-state-in-persistent-storage) +following backend restarts to [S16](../in_review/S16_store-state-in-persistent-storage) when completing S12. The latest status and outcome in S12 survive navigation, page reloads and SSE reconnection while the same backend process remains active. Schools, assignments and scheduling status currently remain in memory. diff --git a/specs/refined/S16_store-state-in-persistent-storage b/specs/in_review/S16_store-state-in-persistent-storage similarity index 85% rename from specs/refined/S16_store-state-in-persistent-storage rename to specs/in_review/S16_store-state-in-persistent-storage index 736383e..9b3a7e6 100644 --- a/specs/refined/S16_store-state-in-persistent-storage +++ b/specs/in_review/S16_store-state-in-persistent-storage @@ -1,6 +1,6 @@ # S16 — Store state in persistent storage -Status: REFINED +Status: IN_REVIEW Agreed after the grill-me interview; shared understanding confirmed. @@ -14,7 +14,8 @@ and application crashes. Use PostgreSQL with relational tables for schools, dancers, coaches, groups, shows and their relationships. Use Flyway to version and apply database schema -changes. +changes. Use JPA with Hibernate ORM for application persistence; keep the mappings +inside the repository layer so the existing domain model remains framework independent. The existing domain has shared relationships and operations on individual school components. Related changes must be atomic, and multiple maintainers must be able @@ -121,32 +122,34 @@ for now. S16 does not promise recovery after loss of the database or its volume. ## Acceptance criteria -- [ ] PostgreSQL stores the complete maintained domain state and saved scheduling +- [x] PostgreSQL stores the complete maintained domain state and saved scheduling results, with Flyway managing the relational schema. -- [ ] Confirmed changes remain available after both a normal restart and an +- [x] Confirmed changes remain available after both a normal restart and an application crash, using the same persistent database. -- [ ] Failed related mutations and failed school replacement imports leave no +- [x] Failed related mutations and failed school replacement imports leave no partial changes or dangling references. -- [ ] Two edits based on the same school revision cannot silently overwrite one +- [x] Two edits based on the same school revision cannot silently overwrite one another: the stale edit receives a clear conflict response, and the user can reload and retry. -- [ ] Scheduling results based on outdated input revisions are rejected. -- [ ] The latest accepted scheduling run, its stable identifier, timestamps, +- [x] Scheduling results based on outdated input revisions are rejected. +- [x] The latest accepted scheduling run, its stable identifier, timestamps, progress, outcome and objective summary survive normal restart and crash and are returned through the status API and SSE snapshots. -- [ ] Successful assignment replacement and completed run status commit +- [x] Successful assignment replacement and completed run status commit atomically; success is published only after durable commit. -- [ ] Calculations left Running become Interrupted before recovered status is +- [x] Calculations left Running become Interrupted before recovered status is served; previous assignments remain, no automatic resumption occurs and a new run can start. Reopening does not replay old completion snackbars. -- [ ] Database unavailability prevents successful mutation acknowledgements; +- [x] Database unavailability prevents successful mutation acknowledgements; startup connection and migration failures are clearly reported. -- [ ] An empty database can be initialized through Flyway and populated through +- [x] An empty database can be initialized through Flyway and populated through existing JSON import, preserving import/export behavior and identities. -- [ ] Setup and transition instructions cover the database service, persistent +- [x] Setup and transition instructions cover the database service, persistent storage, connection configuration, migrations and export-before-transition. ## Implementation handoff -This specification records the agreed design and behavior. Database provisioning, -application changes and validation are subsequent implementation work. +PostgreSQL/JPA/Hibernate storage, Flyway migrations, school revision checks, durable scheduling status and +restart recovery are implemented and verified. See [S16 validation](../S16-validation.md) +and [setup and transition instructions](../../docs/persistent-storage.md). +Implementation is ready for review; final acceptance is pending. diff --git a/tooling/persistence/restart_test.py b/tooling/persistence/restart_test.py new file mode 100644 index 0000000..7188bcf --- /dev/null +++ b/tooling/persistence/restart_test.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Exercise packaged-backend durability against an isolated, already running PostgreSQL container. + +Build with mvn verify first. Never point this test at production: --database-container +is deliberately stopped and restarted to exercise failed mutation acknowledgements. +""" +import argparse +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +ROOT = Path(__file__).resolve().parents[2] +JAVA = str(Path(os.environ['JAVA_HOME']) / 'bin/java') if os.environ.get('JAVA_HOME') else 'java' + + +class Backend: + def __init__(self, args): + self.args = args + self.base = f'http://127.0.0.1:{args.port}' + self.process = None + self.log = None + self.paths = [] + + def start(self, overrides=None, ready=True): + env = dict(os.environ, DATABASE_URL=self.args.url, DATABASE_USER=self.args.user, + DATABASE_PASSWORD=self.args.password, QUARKUS_HTTP_PORT=str(self.args.port)) + env.update(overrides or {}) + self.log = tempfile.NamedTemporaryFile(prefix='s16-backend-', suffix='.log', delete=False) + self.paths.append(self.log.name) + self.process = subprocess.Popen([JAVA, '-jar', str(ROOT / 'agiledancers-be/target/quarkus-app/quarkus-run.jar')], + cwd=ROOT, env=env, stdout=self.log, stderr=subprocess.STDOUT) + if ready: + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if self.process.poll() is not None: + raise AssertionError(f'Backend exited: {Path(self.log.name).read_text()}') + try: + if self.request('/q/health/ready')[0] == 200: + return + except OSError: + pass + time.sleep(.2) + raise AssertionError(f'Backend readiness timeout: {self.log.name}') + + def stop(self, abrupt=False): + if self.process and self.process.poll() is None: + self.process.send_signal(signal.SIGKILL if abrupt else signal.SIGTERM) + self.process.wait(timeout=30) + if self.log: + self.log.close() + + def request(self, path, method='GET', body=None, revision=None): + headers = {'Content-Type': 'application/json'} + if revision is not None: + headers['X-School-Revision'] = str(revision) + request = urllib.request.Request(self.base + path, + data=None if body is None else json.dumps(body).encode(), headers=headers, method=method) + try: + response = urllib.request.urlopen(request, timeout=12) + except urllib.error.HTTPError as failure: + response = failure + with response: + data = response.read() + return response.status, json.loads(data) if data else None + + def mutate(self, school, suffix, method='POST', body=None): + path = f'/api/dance-schools/{school}' + status, current = self.request(path) + assert status == 200, current + return self.request(path + suffix, method, body, current['revision']) + + +def imported(backend, size): + document = dict(formatVersion=3, key=str(uuid.uuid4()), name=f'S16 restart {size}', + dancers=[dict(id=str(index), name=f'Dancer {index}', birthDate='2000-01-01') for index in range(1, size + 1)], coaches=[], + groups=[dict(id=str(index), category=f'Group {index}', type='REGULAR', startAge=0, endAge=99, + styles=['JAZZ'], dancerIds=[str(index)], coachId=None) for index in range(1, size + 1)], + shows=[dict(id='1', name='Saved show', preferredDayParts=1, appearances={'REGULAR': 1}, finalized=False, + dayParts=[dict(date='2027-01-01', type='MORNING', groupIds=['1'])])]) + status, school = backend.request('/api/dance-schools/import', 'POST', document) + assert status == 200, school + status, shows = backend.request(f'/api/dance-schools/{school["id"]}/shows') + assert status == 200 + return school['id'], shows[0]['id'] + + +def run_path(pair): + return f'/api/dance-schools/{pair[0]}/shows/{pair[1]}/schedule' + + +def assert_completed(backend, pair, original): + status, run = backend.request(run_path(pair)) + assert status == 200 and run == original, run + status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows') + assert status == 200 and len(shows[0]['dayParts'][0]['danceGroupIds']) == 1 + + +def assert_interrupted(backend, pair, run, assignments): + status, current = backend.request(run_path(pair)) + assert status == 200 and current['state'] == 'INTERRUPTED', current + assert current['runId'] == run['runId'] and current['finishedAt'] + assert 'behouden' in current['message'] + status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows') + assert status == 200 and shows[0]['dayParts'] == assignments + # Reconnected SSE snapshots carry stable recovered identities. + with urllib.request.urlopen(backend.base + '/api/scheduling/events', timeout=10) as stream: + while True: + line = stream.readline().decode().strip() + if line.startswith('data:'): + snapshot = json.loads(line[5:]) + if snapshot['runs'] is not None: + assert any(item['runId'] == run['runId'] and item['state'] == 'INTERRUPTED' + for item in snapshot['runs']) + break + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--url', default='jdbc:postgresql://127.0.0.1:54329/agiledancers') + parser.add_argument('--user', default='agiledancers') + parser.add_argument('--password', default='s16-test-only') + parser.add_argument('--port', type=int, default=18086) + parser.add_argument('--database-container', required=True) + args = parser.parse_args() + backend = Backend(args) + schools = [] + try: + backend.start() + small = imported(backend, 1) + schools.append(small[0]) + status, started = backend.mutate(small[0], f'/shows/{small[1]}/schedule') + assert status == 202, started + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + _, completed = backend.request(run_path(small)) + if completed['state'] != 'RUNNING': + break + time.sleep(.1) + assert completed['state'] == 'OPTIMAL' and completed['summary'] is not None, completed + backend.stop() + backend.start() + assert_completed(backend, small, completed) + large = imported(backend, 70) + schools.append(large[0]) + _, shows = backend.request(f'/api/dance-schools/{large[0]}/shows') + assignments = shows[0]['dayParts'] + for abrupt in (False, True): + status, run = backend.mutate(large[0], f'/shows/{large[1]}/schedule') + assert status == 202 and run['state'] == 'RUNNING', run + _, current = backend.request(run_path(large)) + assert current['state'] == 'RUNNING', 'Fixture completed too quickly to test interruption' + backend.stop(abrupt=abrupt) + backend.start() + assert_interrupted(backend, large, run, assignments) + assert_completed(backend, small, completed) + _, before = backend.request(f'/api/dance-schools/{small[0]}') + subprocess.run(['docker', 'stop', args.database_container], check=True, stdout=subprocess.DEVNULL) + try: + status, failure = backend.request(f'/api/dance-schools/{small[0]}', 'PUT', {'name': 'Must not persist'}, before['revision']) + assert status == 503 and 'niet opgeslagen' in failure['message'], (status, failure) + assert backend.request('/q/health/ready')[0] == 503 + finally: + subprocess.run(['docker', 'start', args.database_container], check=True, stdout=subprocess.DEVNULL) + backend.stop() + backend.start() + assert_completed(backend, small, completed) + assert backend.request(f'/api/dance-schools/{small[0]}')[1] == before + for school in schools: + assert backend.mutate(school, '', 'DELETE')[0] == 204 + schools.clear() + backend.stop() + backend.start({'DATABASE_URL': 'jdbc:postgresql://127.0.0.1:1/unavailable'}, ready=False) + backend.process.wait(timeout=35) + assert backend.process.returncode != 0 + assert 'Connection' in Path(backend.log.name).read_text() + backend.stop() + schema = 's16_invalid_' + uuid.uuid4().hex + def sql(statement): + subprocess.run(['docker', 'exec', args.database_container, 'psql', '-v', 'ON_ERROR_STOP=1', + '-U', args.user, '-d', args.url.rsplit('/', 1)[-1].split('?')[0], '-c', statement], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + sql(f'CREATE SCHEMA {schema}; CREATE TABLE {schema}.school (blocker integer);') + try: + # Baseline version zero lets V1 run, then fail against the deliberately conflicting table. + backend.start({'QUARKUS_FLYWAY_SCHEMAS': schema, 'QUARKUS_FLYWAY_BASELINE_ON_MIGRATE': 'true', + 'QUARKUS_FLYWAY_BASELINE_VERSION': '0'}, ready=False) + backend.process.wait(timeout=35) + assert backend.process.returncode != 0 + assert 'Migration' in Path(backend.log.name).read_text() + finally: + backend.stop() + sql(f'DROP SCHEMA {schema} CASCADE;') + print('PASS: completed results, graceful interruption, SIGKILL recovery, stable SSE identity, database outage and startup/migration failure') + finally: + backend.stop() + print('Backend logs: ' + ', '.join(backend.paths)) + if schools: + print('Test school IDs retained for diagnostics: ' + ', '.join(map(str, schools))) + + +if __name__ == '__main__': + main() -- 2.49.1 From 2d8822c1a4406a8d39014f2f94c9bf285a04f5e6 Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Wed, 4 Nov 2026 21:54:20 +0100 Subject: [PATCH 2/2] S16 - some transactional hardening --- .../agiledancers/repository/Database.java | 8 +++ .../agiledancers/service/SchedulingRuns.java | 36 ++++++++-- .../service/SchedulingRunsTest.java | 65 +++++++++++++++++++ specs/S16-validation.md | 24 +++++++ .../S16_store-state-in-persistent-storage | 2 +- 5 files changed, 130 insertions(+), 5 deletions(-) rename specs/{in_review => implemented}/S16_store-state-in-persistent-storage (99%) diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java index 8ce8149..8720acb 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java @@ -27,6 +27,12 @@ public class Database { this.manager = manager; } + /** + * Runs work in a new transaction, or joins the current JTA transaction. Callback registration is supported only + * inside transactions started by this instance. Commit callbacks run in registration order after durable commit; + * their exceptions propagate to the caller without rolling back committed work or running remaining callbacks. + * Rollback callbacks run before the original failure is rethrown; a callback exception replaces that failure. + */ public T transaction(final Callable work) throws Exception { try { if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) { @@ -90,10 +96,12 @@ public class Database { } } + /** Registers a callback on this thread's transaction owned by this instance; see {@link #transaction(Callable)}. */ public void afterCommit(final Runnable action) { committed.get().add(action); } + /** Registers a rollback callback under the same ownership requirement as {@link #afterCommit(Runnable)}. */ public void afterRollback(final Runnable action) { rolledBack.get().add(action); } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java index 797f6f5..0bbb62b 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java @@ -18,6 +18,7 @@ import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingRun; import nl.agilecc.agiledancers.model.scheduling.SchedulingState; import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; +import nl.agilecc.agiledancers.repository.StorageException; import nl.agilecc.agiledancers.service.port.SchedulingEngine; /** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */ @@ -173,15 +174,42 @@ public class SchedulingRuns { private void finish(final SchedulingRun completed) { try { - work(() -> { - save(completed); - return null; - }); + while (true) { + try { + work(() -> { + schools.show(completed.schoolId(), completed.showId()); + save(completed); + return null; + }); + return; + } catch (final StorageException unavailable) { + // Keep the run guard and terminal outcome until storage recovers; never publish an uncommitted + // status. + if (executor.isShutdown() || !waitForStorage()) { + return; + } + } catch (final ManagementException removed) { + if (removed.getKind() != ManagementExceptionKind.NOT_FOUND) { + throw removed; + } + return; + } + } } finally { schools.endScheduling(completed.showId()); } } + private boolean waitForStorage() { + try { + Thread.sleep(1000); + return true; + } catch (final InterruptedException stopping) { + Thread.currentThread().interrupt(); + return false; + } + } + private void save(final SchedulingRun run) { if (persistence == null) { publish(run); diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java index a4d9ee1..049bfc7 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java @@ -136,6 +136,71 @@ class SchedulingRunsTest { schools.endScheduling(schools.beginScheduling(school, show.getId()).showId()); } + public @Test void databaseRecoveryPersistsPendingFailureWithoutRestart() throws Exception { + final Show show = show(); + final var entered = new CountDownLatch(1); + final var release = new CountDownLatch(1); + final var persistence = new OutagePersistence(); + final var runs = new SchedulingRuns(schools, blocking(entered, release)); + final java.lang.reflect.Field field = SchedulingRuns.class.getDeclaredField("persistence"); + field.setAccessible(true); + field.set(runs, persistence); + try { + final SchedulingRun started = runs.start(school, show.getId()); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + persistence.available.set(false); + release.countDown(); + assertTrue(persistence.unavailable.await(10, TimeUnit.SECONDS)); + assertEquals(started, persistence.stored.get()); + assertEquals(started, runs.status(school, show.getId())); + assertThrows(ManagementException.class, () -> runs.start(school, show.getId())); + persistence.available.set(true); + final SchedulingRun completed = await(runs, show); + assertEquals(SchedulingState.SOLVER_FAILED, completed.state()); + assertEquals(started.runId(), completed.runId()); + assertEquals(completed, persistence.stored.get()); + schools.endScheduling(schools.beginScheduling(school, show.getId()).showId()); + } finally { + persistence.available.set(true); + release.countDown(); + runs.close(); + } + } + + private static class OutagePersistence extends SchedulingPersistence { + private final CountDownLatch unavailable = new CountDownLatch(2); + private final java.util.concurrent.atomic.AtomicBoolean available = + new java.util.concurrent.atomic.AtomicBoolean(true); + private final AtomicReference stored = new AtomicReference<>(); + + private OutagePersistence() { + super(null, null, null); + } + + public @Override T work(final java.util.concurrent.Callable action) { + if (!available.get()) { + unavailable.countDown(); + throw new nl.agilecc.agiledancers.repository.StorageException(new IllegalStateException("offline")); + } + try { + return action.call(); + } catch (final RuntimeException failure) { + throw failure; + } catch (final Exception failure) { + throw new IllegalStateException(failure); + } + } + + public @Override void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) { + save(run, launch); + } + + public @Override void save(final SchedulingRun run, final Runnable publication) { + stored.set(run); + publication.run(); + } + } + private Show show() { final long group = schools.createGroup( school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())) diff --git a/specs/S16-validation.md b/specs/S16-validation.md index 12eb71e..4979c80 100644 --- a/specs/S16-validation.md +++ b/specs/S16-validation.md @@ -154,3 +154,27 @@ Retained review signals in changed code: One active backend remains the supported deployment model. Global execution capacity, distributed workers, database backup/restore and recovery after database volume loss remain outside S16. S17 owns backup/restore. + +## Review follow-up: database recovery during calculation + +Terminal scheduling outcomes whose transaction fails remain pending in their worker. +The worker retries storage once per second without holding the school monitor between +attempts, retaining its run guard until the terminal status commits. Publication still +happens only after commit. Shutdown interrupts the wait; startup recovery remains +responsible for persisted Running statuses after a process restart. If the show was +removed during the outage, the pending outcome is discarded rather than recreating +its status. The finish method's cognitive complexity 12 and cyclomatic complexity 8 distinguish +retryable storage failures, removed shows and shutdown; these lifecycle decisions +stay in the run owner. SchedulingRuns fan-out is now 34 and its test fixture fan-out +35: the added storage exception, interruption handling and outage fixture belong to +this existing persistence lifecycle boundary. + +The regression test simulates unavailable storage during result persistence, verifies +that no terminal status is published prematurely and duplicate starts remain rejected, +then restores storage without restarting the backend and checks the stable run ID, +persisted failure status and released guard. Public transaction/callback documentation +now specifies transaction ownership, callback timing and exception behavior. + +Follow-up verification: clean Maven verify against PostgreSQL passes all 73 backend +tests and 19 standards-checker tests. Standards and the coverage gate pass; +backend line coverage is 1769/1841 (96.09%). diff --git a/specs/in_review/S16_store-state-in-persistent-storage b/specs/implemented/S16_store-state-in-persistent-storage similarity index 99% rename from specs/in_review/S16_store-state-in-persistent-storage rename to specs/implemented/S16_store-state-in-persistent-storage index 9b3a7e6..3fa8593 100644 --- a/specs/in_review/S16_store-state-in-persistent-storage +++ b/specs/implemented/S16_store-state-in-persistent-storage @@ -1,6 +1,6 @@ # S16 — Store state in persistent storage -Status: IN_REVIEW +Status: IMPLEMENTED Agreed after the grill-me interview; shared understanding confirmed. -- 2.49.1