diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4643f1..44f20e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,10 @@ jobs: - name: Verify run: ./mvnw -B -ntp clean verify + + - name: Verify assembled server + run: | + SERVER_JAR=version-gate-server/target/version-gate-server-0.1.0-SNAPSHOT.jar + jar tf "$SERVER_JAR" | grep -q '^BOOT-INF/' + jar tf "$SERVER_JAR" | grep -q 'version-gate-control-postgres' + jar tf "$SERVER_JAR" | grep -q 'version-gate-snapshot-s3' diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e71f6..bc99f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,18 @@ public version is released. idempotency behavior. - Bounded participant fan-out, exact callback wire-protocol enforcement, and sanitized storage-failure Problem Details with correlation IDs. +- Reusable `version-gate-testkit` contracts for control and snapshot adapters. ### Changed -- Corrected the architecture boundary: concrete control and payload storage - adapters belong in separately released repositories and are not retained in - this core. +- Refactored the repository into SPI, core, PostgreSQL-control, S3-snapshot, + server, and testkit Maven modules. +- Moved HTTP, OpenAPI, callbacks, scheduling, and Spring Boot bootstrap into the + executable `version-gate-server` module. +- Made `ControlStore.beginBuild` atomically allocate coordinator versions and + fencing tokens; clients no longer submit `targetVersion`. +- Added official PostgreSQL-control and S3-snapshot placeholder modules while + keeping concrete adapter implementations outside this refactoring change. - Documented adapter requirements for authoritative lease time, atomic transitions and activation, publication visibility, immutable byte verification, crash safety, and retry semantics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2d96e9..614ce92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,15 +11,15 @@ port signatures, or storage guarantees so the contract can be discussed before implementation. Security reports follow [SECURITY.md](SECURITY.md), not the public issue tracker. -V1 intentionally excludes concrete production storage adapters, client SDKs, -messaging systems, cache layers, generic locks, snapshot merging/restoration, -concurrent candidate versions, and multi-resource or multi-region atomicity. +The current refactor intentionally leaves the official PostgreSQL-control and +S3-snapshot modules as placeholders. Client SDKs, messaging systems, cache +layers, generic locks, snapshot merging/restoration, concurrent candidate +versions, and multi-resource or multi-region atomicity remain outside V1. -This repository is the core and public SPI. Technology-specific implementations -belong in separate adapter repositories, such as the anticipated non-binding -examples `version-gate-storage-postgres` and `version-gate-storage-s3`. Do not -add a storage driver, provider SDK, migration, emulator, container stack, or -adapter integration suite to the core. +Official technology-specific implementations belong in dedicated modules in +this repository. Drivers, provider SDKs, migrations, emulators, container +stacks, and adapter integration tests must remain inside their adapter module; +they must never leak into `version-gate-spi` or `version-gate-core`. ## Development setup @@ -31,13 +31,25 @@ Requirements: Run the complete verification suite: ```bash -./mvnw verify +./mvnw clean verify ``` -Core tests use fakes or mocks at the public ports and require no database, -object store, or container runtime. +Framework-free module tests require no database, object store, or container +runtime. Concrete adapter integration tests may add scoped infrastructure when +their implementation is introduced. -## Core change expectations +## Module boundaries + +- `version-gate-spi`: domain types, stable errors, and infrastructure ports; + JDK only. +- `version-gate-core`: lifecycle and application use cases; SPI and JDK only. +- adapter modules: technology-specific infrastructure. +- `version-gate-server`: Spring MVC, OpenAPI, callbacks, scheduling, bootstrap, + and selected adapter dependencies. +- `version-gate-testkit`: reusable semantic contracts and deterministic test + implementations. + +## Change expectations - Put lifecycle rules in the domain/application boundary, not in controllers. - Keep framework, driver, query-language, and provider SDK types out of the @@ -71,12 +83,12 @@ explain how an adapter can preserve: - missing/corrupt/unavailable storage error mapping; and - crash recovery, orphan handling, and cleanup safety. -Adapter repositories are responsible for their own driver compatibility, -migrations, provider limits, concurrency tests, failure injection, and -integration tests. Core changes should add or update reusable contract tests -where practical so every adapter can demonstrate equivalent behavior. +Adapter modules are responsible for their own driver compatibility, migrations, +provider limits, concurrency tests, failure injection, and integration tests. +SPI changes should update `version-gate-testkit` so every adapter can +demonstrate equivalent behavior. -Format and style checks are part of `./mvnw verify`. Keep commits focused and +Format and style checks are part of `./mvnw clean verify`. Keep commits focused and use an imperative summary line. Pull requests should explain the behavior change, failure semantics, tests run, and compatibility or operational impact. diff --git a/README.md b/README.md index 2d4d5ca..776b7f1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Version Gate -Version Gate is the core coordination module for publishing a set of immutable, -client-produced snapshot components as one visible version. +Version Gate is a standalone service for publishing a set of immutable, +client-produced snapshot components as one visible coordinator version. It solves a narrow problem: readers must not observe a half-refreshed data set while producers replace several related payloads. Producers build a candidate @@ -10,32 +10,22 @@ after it is complete and its active-version pointer is atomically changed. ## Repository status and scope -This repository deliberately contains: - -- the domain model and lifecycle rules; -- application use cases; -- the public `ControlStore`, `SnapshotStore`, and participant gateway SPIs; -- the HTTP API, error, callback-protocol, and OpenAPI contracts; and -- Spring configuration for composing those pieces. - -It deliberately contains **no production storage implementation**. In -particular, there is no retained JDBC, database-vendor, object-storage SDK, -migration, local emulator, container stack, or storage integration-test code. -The core is not a runnable production service by itself. - -A deployable distribution must depend on this core, supply exactly one -`ControlStore` bean and one `SnapshotStore` bean, and configure the participant -gateway. Storage adapters should live in independent repositories so their -drivers, migrations, release cadence, and operational policy do not leak into -the core. - -The anticipated repositories `version-gate-storage-postgres` and -`version-gate-storage-s3` are names for separately versioned adapter projects, -not implementations shipped here. PostgreSQL and S3 are non-binding future -examples; any technology can implement the public SPI if it satisfies the -semantic contract in [Architecture and consistency](docs/architecture.md). -An adapter repository depends on a released version of this core for the port -interfaces and domain value types; the core never depends on an adapter. +This repository is a Maven monorepo containing the stable SPI, framework-free +core, official adapter modules, executable Spring Boot server, and reusable +adapter testkit. Adding an official adapter does not add it automatically to +every distribution; `version-gate-server` declares the selected modules it +packages. + +The PostgreSQL-control and S3-snapshot modules are explicit placeholders in +this refactoring change. They establish ownership and dependency boundaries but +do not yet contain drivers, migrations, SDKs, or production storage beans. +Consequently, the executable server currently fails fast until exactly one +`ControlStore` and one `SnapshotStore` implementation are supplied. + +Any future adapter must implement the semantic contract in +[Architecture and consistency](docs/architecture.md). Infrastructure types +never cross the SPI, and neither `version-gate-spi` nor `version-gate-core` +depends on Spring, JDBC, an object-storage SDK, or an adapter. ## Why immutable versions? @@ -53,8 +43,10 @@ Only step 5 makes the version public. A failed upload, expired lease, process restart, or unsuccessful activation leaves the previous version readable. `beginBuild` is version-oriented concurrency control, not a generic distributed -lock. Each resource has at most one non-terminal build, and every mutating build -operation presents the current fencing token. +lock. Version Gate allocates the next coordinator version and fencing token +atomically; clients do not choose either value. Failed and abandoned versions +are never reused, so gaps are valid. Source-system versions belong in separate +provenance metadata and are not coordinator versions. ## Snapshot policies @@ -99,31 +91,23 @@ immutable. ```mermaid flowchart LR - P[Producers] -->|fenced commands and streams| H[HTTP contract] - R[Readers] -->|manifest and component reads| H - H --> A[Application use cases] - A --> D[Domain lifecycle] - A --> C[ControlStore SPI] - A --> S[SnapshotStore SPI] - C --> CA[External control adapter] - S --> SA[External payload adapter] - A --> Q[Participant callback gateway] + Clients --> Server + Server --> Core + Core --> SPI + SPI --> ControlAdapter + SPI --> SnapshotAdapter ``` -The core has no dependency on a concrete storage driver: +Module responsibilities and dependency direction: ```text -io.github.kbarseghyan.versiongate -├── api -├── domain -├── application -├── port -│ ├── ControlStore -│ ├── SnapshotStore -│ └── ParticipantGateway -├── adapter -│ └── http -└── configuration +version-gate/ +├── version-gate-spi domain types, stable errors, infrastructure ports +├── version-gate-core lifecycle and application use cases +├── version-gate-control-postgres official PostgreSQL ControlStore module +├── version-gate-snapshot-s3 official S3-compatible SnapshotStore module +├── version-gate-server HTTP, OpenAPI, callbacks, scheduling, bootstrap +└── version-gate-testkit reusable adapter contracts and deterministic stores ``` An adapter is not correct merely because it implements the Java methods. It @@ -142,12 +126,13 @@ must preserve the SPI's transactional and failure semantics: See [Architecture and consistency](docs/architecture.md) before implementing or selecting an adapter. -## Composing a runnable distribution +## Server assembly -Spring Boot auto-configuration constructs the use cases, scheduler, HTTP API, -and participant callback gateway from the public ports; composition does not -depend on the consuming application's component-scan package. A distribution -supplies storage beans in its own configuration: +`version-gate-server` produces an executable Spring Boot JAR and declares the +selected official adapter modules. Spring Boot auto-configuration constructs +the use cases, scheduler, HTTP API, and participant callback gateway from the +public ports. Until the placeholder adapters are implemented, a local +composition can supply test or experimental storage beans: ```java @Configuration(proxyBeanMethods = false) @@ -165,11 +150,9 @@ class DistributionStorageConfiguration { } ``` -The distribution should package its own application entry point (or invoke the -optional core bootstrap with the adapter artifacts on its classpath), and own adapter -credentials, migrations, health checks, backup, retention, and deployment -configuration. Starting this repository without those beans is expected to fail -fast instead of silently using an in-memory or unsafe fallback. +The selected adapter modules own credentials, migrations, health checks, +backup, retention, and deployment configuration. Starting without the required +beans fails fast instead of silently using an in-memory or unsafe fallback. Core configuration keys are illustrated in [config/application-local.example.yml](config/application-local.example.yml). @@ -190,8 +173,7 @@ When a composed distribution is running, it exposes: ## API workflow for a composed distribution The following `CLIENT_MANAGED` example assumes a correctly composed distribution -is already listening at `http://localhost:8080`. The core repository does not -provide the storage adapters needed to start that process. +is already listening at `http://localhost:8080`. ```bash set -euo pipefail @@ -213,13 +195,13 @@ BUILD_JSON="$( -X POST "$API/resources/$RESOURCE/builds" \ -H 'Content-Type: application/json' \ -d '{ - "targetVersion": 1, "owner": "catalog-publisher", "leaseSeconds": 300 }' )" BUILD_ID="$(printf '%s' "$BUILD_JSON" | jq -r .buildId)" +VERSION="$(printf '%s' "$BUILD_JSON" | jq -r .targetVersion)" FENCING_TOKEN="$(printf '%s' "$BUILD_JSON" | jq -r .fencingToken)" curl --fail-with-body --silent --show-error \ @@ -269,7 +251,7 @@ curl --fail-with-body --silent --show-error \ "$API/resources/$RESOURCE/versions/active/manifest" | jq . curl --fail-with-body --silent --show-error \ - "$API/resources/$RESOURCE/versions/1/components/products" + "$API/resources/$RESOURCE/versions/$VERSION/components/products" ``` Uploads require `Content-Length`; `curl --data-binary @file` supplies it for a @@ -354,7 +336,8 @@ documentation must describe any stronger reconciliation it provides. V1 intentionally has: - one candidate build per resource and no multi-resource atomic activation; -- no concrete production storage adapter in this repository; +- placeholder PostgreSQL-control and S3-snapshot modules, but no concrete + production adapter implementation yet; - no snapshot merging, business-schema validation, or restoration; - no messaging system, event sourcing, Java client SDK, or generic lock API; - no proof that coordinated participants stopped writes; @@ -366,12 +349,13 @@ V1 intentionally has: Requirements are JDK 21 and a POSIX-like shell. The Maven Wrapper pins Maven: ```bash -./mvnw verify +./mvnw clean verify ``` -Core tests use fakes/mocks at the public ports and require no container runtime. -Concrete adapter repositories are responsible for their own compatibility, -concurrency, failure-injection, migration, and integration tests. +The command verifies every module. `version-gate-testkit` exposes reusable +`ControlStoreContract` and `SnapshotStoreContract` suites; each concrete +adapter must also add its own concurrency, failure-injection, migration, and +provider integration tests. See [CONTRIBUTING.md](CONTRIBUTING.md) for change expectations. diff --git a/config/application-local.example.yml b/config/application-local.example.yml index 74f15ec..68c6761 100644 --- a/config/application-local.example.yml +++ b/config/application-local.example.yml @@ -1,6 +1,6 @@ -# Core-only configuration example. It contains no backing-store settings. -# A runnable distribution must provide ControlStore and SnapshotStore beans from -# separately packaged adapters and add their configuration in its own namespace. +# Server configuration example. Adapter settings belong to their own namespaces. +# The placeholder adapter modules do not yet provide ControlStore or SnapshotStore +# beans, so a local composition must supply them explicitly. spring: lifecycle: diff --git a/docs/architecture.md b/docs/architecture.md index 8bc5087..2f45edf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,39 +14,29 @@ The core safety property is: This property applies to the Version Gate metadata pointer. It does not imply that independently captured payloads represent one business transaction. -## Core and adapter boundary +## Module and adapter boundary ```mermaid flowchart LR - P[Snapshot producers] -->|fenced HTTP commands
streamed components| H[HTTP contract] - R[Snapshot readers] -->|manifest and component reads| H - H --> A[Application use cases] - A --> D[Domain lifecycle] - A --> C[ControlStore SPI] - A --> S[SnapshotStore SPI] - C --> CA[External control adapter] - S --> SA[External payload adapter] - A --> Q[Participant callback gateway] - Q --> CP[Quiesce participants] + Clients --> Server + Server --> Core + Core --> SPI + SPI --> ControlAdapter + SPI --> SnapshotAdapter ``` -This repository contains the domain, application use cases, public ports, and -HTTP/protocol/configuration contracts. It does not contain a production -`ControlStore` or `SnapshotStore`. - -Concrete stores belong in independently released adapter repositories. The -names `version-gate-storage-postgres` and `version-gate-storage-s3` describe -anticipated, non-binding future examples; neither implementation is retained in -this core. Other technologies are equally valid when they satisfy the complete -contract below. - -Each adapter depends on a released core artifact for the public port interfaces -and domain value types. Dependency direction never points from the core to an -adapter, and an adapter declares which core/SPI versions it supports. - -A runnable distribution depends on the core and selected adapter artifacts and -provides exactly one Spring bean for each storage port. Missing beans are a -composition error and should fail startup. The distribution owns driver +The root is a Maven aggregator. `version-gate-spi` owns domain values, stable +errors, and infrastructure ports without framework dependencies. +`version-gate-core` owns lifecycle and use cases and depends only on the SPI and +JDK. Official adapters live beside them as independent modules. +`version-gate-server` owns HTTP, OpenAPI, callbacks, scheduling, bootstrap, and +the explicit selection of adapter modules included in its executable JAR. +`version-gate-testkit` supplies reusable semantic contract tests. + +The current PostgreSQL-control and S3-snapshot modules are placeholders: they +establish the official boundaries but intentionally contain no concrete +drivers, SDKs, migrations, or storage beans yet. Missing beans remain a +composition error and fail startup. Future adapter modules own their driver configuration, credentials, migrations, health checks, backup, retention, and operational documentation. @@ -54,13 +44,24 @@ Framework, driver, query-language, and provider SDK types must not cross the ports. An adapter may strengthen durability or observability but must not weaken the public semantics. +The architectural rule is: + +```text +SPI defines guarantees +core defines lifecycle +adapters implement infrastructure +server assembles a selected distribution +testkit proves adapter compatibility +``` + ## Global invariants The application and adapters jointly preserve: 1. Resource IDs are unique. 2. A resource has at most one non-terminal build. -3. A target version is unique within a resource. +3. Coordinator versions are monotonically allocated by the control store and + never reused within a resource. 4. A component identity is unique within a resource version. 5. Each resource has at most one active-version pointer. 6. Every build mutation validates the current fencing token. @@ -105,7 +106,7 @@ Operation-specific mappings are: | Operation | Stable contract errors beyond common validation/storage failures | | --- | --- | | `registerResource` | Existing ID: `RESOURCE_ALREADY_EXISTS` | -| `beginBuild` | Unknown resource: `RESOURCE_NOT_FOUND`; live candidate: `BUILD_ALREADY_EXISTS`; reused, non-increasing, or already active target: `VERSION_ALREADY_EXISTS` | +| `beginBuild` | Unknown resource: `RESOURCE_NOT_FOUND`; live candidate: `BUILD_ALREADY_EXISTS`; allocation failure or incoherent sequence state: `STORAGE_FAILURE` | | `renewBuild`, snapshot transitions | Common build/fence/lease errors; wrong source state or policy: `INVALID_BUILD_TRANSITION` | | `registerSnapshotComponent` | Wrong identity or non-required component: `VALIDATION_FAILED`; different immutable tuple: `COMPONENT_CONFLICT` | | `completeBuild` | Missing required metadata: `INCOMPLETE_SNAPSHOT` | @@ -122,11 +123,14 @@ Backend unavailability, serialization failure, or incoherent persisted state is - Mutations for one resource/build must have a well-defined serialization point. -- `beginBuild` atomically verifies resource existence, target-version - uniqueness, and absence of another non-terminal build before creating the - candidate. +- `beginBuild` atomically verifies resource existence and absence of another + non-terminal build, allocates the next coordinator version and fencing token, + and persists the `BUILDING` candidate. - Two concurrent begins cannot both succeed. A read-before-write check without an atomic constraint, lock, or compare-and-set is insufficient. +- Coordinator versions are never supplied by clients. They increase + monotonically per resource and are not reused after a build fails or is + abandoned; gaps are valid. - A fencing generation is monotonically increasing for a resource and is never reused after ownership changes. - The build records the active version observed at begin time as diff --git a/docs/coordinated-quiesce.md b/docs/coordinated-quiesce.md index e2c95dd..3ff9173 100644 --- a/docs/coordinated-quiesce.md +++ b/docs/coordinated-quiesce.md @@ -8,7 +8,8 @@ every participant honoring the protocol. This repository defines the protocol and implements its core use cases and HTTP callback gateway. Durable build/participant progress and immutable component payloads come from the `ControlStore` and `SnapshotStore` supplied by a composed -distribution. No production storage adapter is included here. +distribution. The official PostgreSQL-control and S3-snapshot modules are +placeholders until their concrete implementations are added. No Java SDK is required. A participant exposes: @@ -60,6 +61,10 @@ All four callbacks receive the same JSON shape. This example represents a } ``` +`targetVersion` in this callback is the coordinator version already allocated +by Version Gate. It is output context for the participant, not a value supplied +in the public begin-build request. + Each request also carries: ```http @@ -131,7 +136,7 @@ after it enters `QUIESCING`: changing `leaseExpiresAt` would change the callback body while reusing the same idempotent action ID. V1 sends callbacks synchronously and applies the configured timeout to each -request. The core defaults to at most eight registered participants per +request. The server defaults to at most eight registered participants per resource and always enforces a hard maximum of 32. A distribution should choose a smaller configured maximum when necessary so the complete quiesce, capture, resume, and failure-cleanup window fits its lease and ingress timeout budgets. diff --git a/pom.xml b/pom.xml index c6d56ae..9b65108 100644 --- a/pom.xml +++ b/pom.xml @@ -14,8 +14,9 @@ io.github.kbarseghyan version-gate 0.1.0-SNAPSHOT + pom version-gate - A service for coordinating, storing, and atomically publishing immutable snapshots of distributed data. + Parent and reactor for the Version Gate service and its official adapters. https://github.com/k-barseghyan/version-gate @@ -48,36 +49,44 @@ 3.5.5 - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-webmvc - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - ${springdoc.version} - - - org.springframework.boot - spring-boot-configuration-processor - true - + + version-gate-spi + version-gate-core + version-gate-testkit + version-gate-control-postgres + version-gate-snapshot-s3 + version-gate-server + - - org.springframework.boot - spring-boot-starter-test - test - - + + + + ${project.groupId} + version-gate-spi + ${project.version} + + + ${project.groupId} + version-gate-core + ${project.version} + + + ${project.groupId} + version-gate-testkit + ${project.version} + + + ${project.groupId} + version-gate-control-postgres + ${project.version} + + + ${project.groupId} + version-gate-snapshot-s3 + ${project.version} + + + @@ -109,19 +118,6 @@ maven-surefire-plugin ${maven-surefire.version} - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.springframework.boot - spring-boot-configuration-processor - ${project.parent.version} - - - - com.diffplug.spotless spotless-maven-plugin diff --git a/version-gate-control-postgres/pom.xml b/version-gate-control-postgres/pom.xml new file mode 100644 index 0000000..1f1573a --- /dev/null +++ b/version-gate-control-postgres/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-control-postgres + version-gate-control-postgres + Official PostgreSQL control-store adapter boundary for Version Gate. + + + + ${project.groupId} + version-gate-spi + + + diff --git a/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java b/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java new file mode 100644 index 0000000..009cf38 --- /dev/null +++ b/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java @@ -0,0 +1,7 @@ +/** + * PostgreSQL control-store adapter boundary. + * + *

The module is intentionally a placeholder during the multi-module refactor. A concrete + * implementation will provide a durable {@code ControlStore} without changing the SPI or core. + */ +package io.github.kbarseghyan.versiongate.control.postgres; diff --git a/version-gate-core/pom.xml b/version-gate-core/pom.xml new file mode 100644 index 0000000..3d7af5c --- /dev/null +++ b/version-gate-core/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-core + version-gate-core + Framework-free Version Gate lifecycle and application use cases. + + + + ${project.groupId} + version-gate-spi + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java similarity index 93% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java rename to version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java index 81dc85d..040d0e9 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java +++ b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java @@ -1,12 +1,16 @@ -package io.github.kbarseghyan.versiongate.domain; +package io.github.kbarseghyan.versiongate.application; import io.github.kbarseghyan.versiongate.api.ErrorCode; import io.github.kbarseghyan.versiongate.api.VersionGateException; +import io.github.kbarseghyan.versiongate.domain.Build; +import io.github.kbarseghyan.versiongate.domain.BuildStatus; +import io.github.kbarseghyan.versiongate.domain.Resource; +import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy; import java.time.Instant; import java.util.Objects; -/** Pure lifecycle and fencing rules shared by application logic and adapter contract tests. */ -public final class BuildLifecycle { +/** Pure lifecycle and fencing rules used by the application layer. */ +final class BuildLifecycle { private BuildLifecycle() {} diff --git a/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java similarity index 98% rename from src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java rename to version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java index 806a20c..9500ea2 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java +++ b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java @@ -3,7 +3,6 @@ import io.github.kbarseghyan.versiongate.api.ErrorCode; import io.github.kbarseghyan.versiongate.api.VersionGateException; import io.github.kbarseghyan.versiongate.domain.Build; -import io.github.kbarseghyan.versiongate.domain.BuildLifecycle; import io.github.kbarseghyan.versiongate.domain.BuildStatus; import io.github.kbarseghyan.versiongate.domain.DomainValidation; import io.github.kbarseghyan.versiongate.domain.Participant; @@ -156,7 +155,10 @@ public Resource getResource(String resourceId) { /** * Atomically starts a uniquely fenced candidate build. * - * @param command target resource, version, owner, and initial lease + *

The authoritative control store allocates both the coordinator version and fencing token. + * Clients supply only ownership metadata and the requested initial lease. + * + * @param command target resource, owner, and initial lease * @return newly created build */ public Build beginBuild(BeginBuildCommand command) { @@ -164,15 +166,11 @@ public Build beginBuild(BeginBuildCommand command) { validateInput( () -> { DomainValidation.requireIdentifier(command.resourceId(), "resourceId"); - if (command.targetVersion() < 0) { - throw new IllegalArgumentException("targetVersion must not be negative"); - } DomainValidation.requireNonBlank( command.owner(), "owner", DomainValidation.TEXT_MAX_LENGTH); }); validateLease(command.leaseDuration()); - return controlStore.beginBuild( - command.resourceId(), command.targetVersion(), command.owner(), command.leaseDuration()); + return controlStore.beginBuild(command.resourceId(), command.owner(), command.leaseDuration()); } /** @@ -872,12 +870,10 @@ public record RegisterResourceCommand( * Command to start one fenced candidate build. * * @param resourceId registered resource identifier - * @param targetVersion non-negative version to produce * @param owner caller-defined owner for diagnostics * @param leaseDuration requested positive initial lease */ - public record BeginBuildCommand( - String resourceId, long targetVersion, String owner, Duration leaseDuration) {} + public record BeginBuildCommand(String resourceId, String owner, Duration leaseDuration) {} /** * Command to renew one fenced build lease. diff --git a/src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java similarity index 95% rename from src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java rename to version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java index 87c5a5f..6b19162 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java +++ b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java @@ -1,4 +1,4 @@ -package io.github.kbarseghyan.versiongate.domain; +package io.github.kbarseghyan.versiongate.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -6,6 +6,12 @@ import io.github.kbarseghyan.versiongate.api.ErrorCode; import io.github.kbarseghyan.versiongate.api.VersionGateException; +import io.github.kbarseghyan.versiongate.domain.Build; +import io.github.kbarseghyan.versiongate.domain.BuildStatus; +import io.github.kbarseghyan.versiongate.domain.DomainValidation; +import io.github.kbarseghyan.versiongate.domain.Participant; +import io.github.kbarseghyan.versiongate.domain.Resource; +import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy; import java.net.URI; import java.time.Instant; import java.util.List; diff --git a/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java similarity index 99% rename from src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java rename to version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java index 18f27b0..02093d6 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java +++ b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java @@ -149,7 +149,7 @@ void registrationAndOwnerLimitsAreRejectedBeforeControlStorage() { () -> service.beginBuild( new VersionGateService.BeginBuildCommand( - "catalog", 1, "x".repeat(256), Duration.ofMinutes(5)))); + "catalog", "x".repeat(256), Duration.ofMinutes(5)))); verifyNoInteractions(controlStore); verifyNoInteractions(snapshotStore); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/version-gate-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename to version-gate-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/version-gate-server/pom.xml b/version-gate-server/pom.xml new file mode 100644 index 0000000..bb84352 --- /dev/null +++ b/version-gate-server/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-server + version-gate-server + Executable Spring Boot assembly for Version Gate. + + + + ${project.groupId} + version-gate-spi + + + ${project.groupId} + version-gate-core + + + ${project.groupId} + version-gate-control-postgres + runtime + + + ${project.groupId} + version-gate-snapshot-s3 + runtime + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java similarity index 64% rename from src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java index 594992b..7601e1c 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java @@ -5,19 +5,20 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; /** - * Optional bootstrap for a classpath that also contains contract-compliant storage adapters. + * Bootstrap for the assembled Version Gate server. * - *

The core artifact alone intentionally fails startup because it provides no storage beans. + *

Startup intentionally fails when the selected adapter modules provide no compatible storage + * beans. */ @SpringBootConfiguration @EnableAutoConfiguration public class VersionGateApplication { - /** Creates the optional Spring Boot bootstrap configuration. */ + /** Creates the Spring Boot bootstrap configuration. */ public VersionGateApplication() {} /** - * Starts an optional assembled distribution. + * Starts the assembled distribution. * * @param args Spring Boot command-line arguments */ diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java similarity index 99% rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java index 9b50b94..ce7ce69 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java @@ -506,13 +506,12 @@ Participant toDomain() { } record BeginBuildRequest( - @NotNull @PositiveOrZero Long targetVersion, @NotBlank @Size(max = DomainValidation.TEXT_MAX_LENGTH) String owner, @NotNull @Positive Long leaseSeconds) { VersionGateService.BeginBuildCommand toCommand(String resourceId) { return new VersionGateService.BeginBuildCommand( - resourceId, targetVersion, owner, Duration.ofSeconds(leaseSeconds)); + resourceId, owner, Duration.ofSeconds(leaseSeconds)); } } diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java similarity index 94% rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java index 1d97f63..8c62a94 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java @@ -13,7 +13,7 @@ /** * Registers the core HTTP API and the default participant callback implementation. * - *

A consuming application may replace the callback behavior with its own {@code + *

An assembled distribution may replace the callback behavior with its own {@code * ParticipantGateway} bean. */ @Configuration(proxyBeanMethods = false) diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java similarity index 85% rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java index 081f103..e8424bc 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java @@ -4,7 +4,7 @@ import org.springframework.context.annotation.Import; /** - * Spring Boot entry point that makes the core composable from separate adapter artifacts. + * Spring Boot entry point that composes the core and selected adapter artifacts. * *

The auto-configuration intentionally fails fast when the application supplies neither a * control-store nor a snapshot-store adapter. diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java similarity index 87% rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java index 1bddec1..96af021 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java @@ -15,10 +15,10 @@ import org.springframework.scheduling.annotation.EnableScheduling; /** - * Composes the core around externally supplied storage-port beans. + * Composes the server around storage-port beans supplied by selected adapters. * - *

A consuming application must provide exactly one compatible {@code ControlStore} and {@code - * SnapshotStore}. No production storage implementation is supplied by the core artifact. + *

The assembled server must provide exactly one compatible {@code ControlStore} and {@code + * SnapshotStore}. Placeholder adapter modules intentionally provide neither. */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(VersionGateProperties.class) @@ -26,7 +26,7 @@ @Import(VersionGateHttpConfiguration.class) public class VersionGateConfiguration { - /** Creates the core bean configuration. */ + /** Creates the server bean configuration. */ public VersionGateConfiguration() {} @Bean diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java similarity index 96% rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java index 96b5dee..658e69c 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java +++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java @@ -9,7 +9,7 @@ import org.springframework.util.unit.DataSize; /** - * Core configuration contract; storage adapters define their own independent namespaces. + * Server configuration contract; storage adapters define their own independent namespaces. * * @param maximumLease largest lease duration accepted by application use cases * @param maximumComponentSize largest accepted component representation @@ -26,7 +26,7 @@ public record VersionGateProperties( @DefaultValue ParticipantProperties participant) { /** - * Creates validated core configuration. + * Creates validated server configuration. * * @param maximumLease largest lease duration accepted by application use cases * @param maximumComponentSize largest accepted component representation diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/version-gate-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to version-gate-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java similarity index 100% rename from src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java diff --git a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java similarity index 99% rename from src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java index 76cbe68..949458d 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java +++ b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java @@ -152,7 +152,6 @@ void mapsLeaseSecondsForBeginAndRenew() throws Exception { .content( """ { - "targetVersion": 5, "owner": "catalog-publisher", "leaseSeconds": 300 } @@ -161,7 +160,7 @@ void mapsLeaseSecondsForBeginAndRenew() throws Exception { .andExpect( header() .string(HttpHeaders.LOCATION, "http://localhost/resources/catalog/builds/current")) - .andExpect(jsonPath("$.targetVersion").value(5)); + .andExpect(jsonPath("$.targetVersion").value(1)); assertThat(controlStore.lastLeaseDuration).isEqualTo(Duration.ofSeconds(300)); @@ -374,7 +373,6 @@ void rejectsOverlengthRegistrationAndBuildFieldsBeforeControlStorage() throws Ex .content( """ { - "targetVersion": 5, "owner": "%s", "leaseSeconds": 300 } @@ -624,14 +622,13 @@ public Optional findResource(String resourceId) { } @Override - public Build beginBuild( - String resourceId, long targetVersion, String owner, Duration leaseDuration) { + public Build beginBuild(String resourceId, String owner, Duration leaseDuration) { lastLeaseDuration = leaseDuration; build = new Build( BUILD_ID, resourceId, - targetVersion, + 1, resource.activeVersion(), BuildStatus.BUILDING, owner, diff --git a/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java similarity index 100% rename from src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java diff --git a/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/version-gate-snapshot-s3/pom.xml b/version-gate-snapshot-s3/pom.xml new file mode 100644 index 0000000..753242a --- /dev/null +++ b/version-gate-snapshot-s3/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-snapshot-s3 + version-gate-snapshot-s3 + Official S3-compatible snapshot-store adapter boundary for Version Gate. + + + + ${project.groupId} + version-gate-spi + + + diff --git a/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java b/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java new file mode 100644 index 0000000..150e886 --- /dev/null +++ b/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java @@ -0,0 +1,8 @@ +/** + * S3-compatible snapshot-store adapter boundary. + * + *

The module is intentionally a placeholder during the multi-module refactor. A concrete + * implementation will provide an immutable streaming {@code SnapshotStore} without changing the SPI + * or core. + */ +package io.github.kbarseghyan.versiongate.snapshot.s3; diff --git a/version-gate-spi/pom.xml b/version-gate-spi/pom.xml new file mode 100644 index 0000000..8c887a7 --- /dev/null +++ b/version-gate-spi/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-spi + version-gate-spi + Framework-free domain and infrastructure contracts for Version Gate. + diff --git a/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java similarity index 94% rename from src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java index 98b1b44..c87bda1 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java +++ b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java @@ -18,7 +18,7 @@ /** * Authoritative storage contract for Version Gate control state. * - *

Implementations are production adapters supplied outside this repository. They must be + *

Implementations are supplied by official or third-party adapter modules. They must be * thread-safe, durable across process restarts, and enforce the documented atomicity rather than * relying on application-layer prechecks. In particular, an adapter owns the authoritative clock: * it must evaluate lease expiry inside the same critical section as each mutation and only after @@ -66,20 +66,20 @@ Resource registerResource( Optional findResource(String resourceId); /** - * Atomically creates a fenced candidate build only when the resource has no non-terminal build. + * Atomically allocates a coordinator version and fencing token and creates a candidate build. * *

The adapter may abandon an expired candidate in the same critical section. Fencing tokens - * must never be reused for a resource, the target version must be unique, and it must be greater - * than the active version when one exists. For a coordinated resource, the same atomic operation - * initializes exactly one {@code PENDING} participant state for every registered participant. + * and coordinator versions must be monotonically increasing and never reused for a resource. + * Failed and abandoned builds keep their allocated versions; gaps are valid. For a coordinated + * resource, the same atomic operation initializes exactly one {@code PENDING} participant state + * for every registered participant. * * @param resourceId registered resource identifier - * @param targetVersion non-negative version to produce * @param owner caller-defined build owner * @param leaseDuration positive initial lease duration - * @return newly created fenced build + * @return newly created build containing the allocated version and fencing token */ - Build beginBuild(String resourceId, long targetVersion, String owner, Duration leaseDuration); + Build beginBuild(String resourceId, String owner, Duration leaseDuration); /** * Atomically renews a live build after validating its fence, status, and unexpired lease. diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java similarity index 100% rename from src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java similarity index 98% rename from src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java index 7e8c351..e5ec892 100644 --- a/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java +++ b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java @@ -8,7 +8,7 @@ /** * Storage-neutral contract for immutable snapshot payloads. * - *

Implementations live in separately versioned adapter libraries. They must stream payloads + *

Implementations live in official or third-party adapter modules. They must stream payloads * without materializing the entire content in memory, enforce immutable logical keys, and validate * SHA-256 over the exact stored representation (after any content encoding). Backend SDK types, * checksums, and exceptions must not cross this boundary. diff --git a/version-gate-testkit/pom.xml b/version-gate-testkit/pom.xml new file mode 100644 index 0000000..f2b6e3b --- /dev/null +++ b/version-gate-testkit/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + io.github.kbarseghyan + version-gate + 0.1.0-SNAPSHOT + + + version-gate-testkit + version-gate-testkit + Reusable semantic contract tests and deterministic stores for Version Gate adapters. + + + + ${project.groupId} + version-gate-spi + + + org.junit.jupiter + junit-jupiter-api + + + + ${project.groupId} + version-gate-core + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + diff --git a/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java new file mode 100644 index 0000000..1ef9754 --- /dev/null +++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java @@ -0,0 +1,94 @@ +package io.github.kbarseghyan.versiongate.testkit; + +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 static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.kbarseghyan.versiongate.api.ErrorCode; +import io.github.kbarseghyan.versiongate.api.VersionGateException; +import io.github.kbarseghyan.versiongate.domain.Build; +import io.github.kbarseghyan.versiongate.domain.BuildStatus; +import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy; +import io.github.kbarseghyan.versiongate.port.ControlStore; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Reusable minimum semantic contract for {@link ControlStore} implementations. + * + *

Adapter tests extend this class and construct a fresh store backed by the supplied + * deterministic clock. Adapters may add stronger integration tests for isolation, crash recovery, + * migrations, and their storage-authoritative clock. + */ +public abstract class ControlStoreContract { + + private static final Instant NOW = Instant.parse("2030-01-02T03:04:05Z"); + private static final Duration LEASE = Duration.ofMinutes(5); + + private MutableClock clock; + private ControlStore store; + + /** + * Creates an empty control store for one contract-test invocation. + * + * @param clock deterministic authoritative clock for the test store + * @return empty store instance + */ + protected abstract ControlStore createControlStore(Clock clock); + + @BeforeEach + final void initializeContractStore() { + clock = new MutableClock(NOW); + store = createControlStore(clock); + } + + @Test + final void allocatesMonotonicCoordinatorVersionsAndFencingTokens() { + registerClientResource(); + + Build first = store.beginBuild("catalog", "first-owner", LEASE); + Build abandoned = store.abortBuild(first.buildId(), first.fencingToken()); + Build second = store.beginBuild("catalog", "second-owner", LEASE); + + assertEquals(BuildStatus.ABANDONED, abandoned.status()); + assertTrue(first.targetVersion() >= 0); + assertTrue(second.targetVersion() > first.targetVersion()); + assertTrue(second.fencingToken() > first.fencingToken()); + assertNull(first.baseActiveVersion()); + assertNull(second.baseActiveVersion()); + } + + @Test + final void rejectsASecondLiveBuildWithoutAllocatingClientChosenState() { + registerClientResource(); + Build first = store.beginBuild("catalog", "first-owner", LEASE); + + VersionGateException failure = + assertThrows( + VersionGateException.class, () -> store.beginBuild("catalog", "second-owner", LEASE)); + + assertEquals(ErrorCode.BUILD_ALREADY_EXISTS, failure.code()); + assertEquals(first, store.findCurrentBuild("catalog").orElseThrow()); + } + + @Test + final void expiresTheCurrentBuildUsingTheAdapterClock() { + registerClientResource(); + Build build = store.beginBuild("catalog", "owner", Duration.ofSeconds(10)); + + clock.advance(Duration.ofSeconds(10)); + + assertTrue(store.findCurrentBuild("catalog").isEmpty()); + assertEquals(BuildStatus.ABANDONED, store.findBuild(build.buildId()).orElseThrow().status()); + } + + private void registerClientResource() { + store.registerResource("catalog", SnapshotPolicy.CLIENT_MANAGED, Set.of("products"), List.of()); + } +} diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java similarity index 95% rename from src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java index 2dd9b5e..51961a3 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java +++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java @@ -1,4 +1,4 @@ -package io.github.kbarseghyan.versiongate.support; +package io.github.kbarseghyan.versiongate.testkit; import io.github.kbarseghyan.versiongate.api.ErrorCode; import io.github.kbarseghyan.versiongate.api.VersionGateException; @@ -36,6 +36,7 @@ public final class InMemoryControlStore implements ControlStore { private final Map builds = new HashMap<>(); private final Map currentBuilds = new HashMap<>(); private final Map versionBuilds = new HashMap<>(); + private final Map lastAllocatedVersions = new HashMap<>(); private final Map lastFencingTokens = new HashMap<>(); private final Map components = new HashMap<>(); private final Map manifests = new HashMap<>(); @@ -43,6 +44,11 @@ public final class InMemoryControlStore implements ControlStore { private long nextBuildSequence = 1; + /** + * Creates an empty deterministic store. + * + * @param clock authoritative clock used for lease decisions + */ public InMemoryControlStore(Clock clock) { this.clock = Objects.requireNonNull(clock); } @@ -71,8 +77,7 @@ public synchronized Optional findResource(String resourceId) { } @Override - public synchronized Build beginBuild( - String resourceId, long targetVersion, String owner, Duration leaseDuration) { + public synchronized Build beginBuild(String resourceId, String owner, Duration leaseDuration) { Resource resource = requireResource(resourceId); Instant now = now(); UUID currentId = currentBuilds.get(resourceId); @@ -89,23 +94,31 @@ public synchronized Build beginBuild( currentBuilds.remove(resourceId); } } + if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) { + throw error(ErrorCode.VALIDATION_FAILED, "lease duration must be positive"); + } + + long targetVersion; + try { + targetVersion = Math.addExact(lastAllocatedVersions.getOrDefault(resourceId, 0L), 1L); + } catch (ArithmeticException exhausted) { + throw error( + ErrorCode.STORAGE_FAILURE, + "Coordinator version space is exhausted for resource " + resourceId); + } ResourceVersion versionKey = new ResourceVersion(resourceId, targetVersion); if (versionBuilds.containsKey(versionKey)) { throw error( - ErrorCode.VERSION_ALREADY_EXISTS, - "Version " + targetVersion + " already exists for " + resourceId); + ErrorCode.STORAGE_FAILURE, + "Coordinator version allocation is inconsistent for resource " + resourceId); } - if (resource.activeVersion() != null && targetVersion <= resource.activeVersion()) { + long fencingToken; + try { + fencingToken = Math.addExact(lastFencingTokens.getOrDefault(resourceId, 0L), 1L); + } catch (ArithmeticException exhausted) { throw error( - ErrorCode.VERSION_ALREADY_EXISTS, - "Target version must be greater than the active version"); + ErrorCode.STORAGE_FAILURE, "Fencing-token space is exhausted for resource " + resourceId); } - if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) { - throw error(ErrorCode.VALIDATION_FAILED, "lease duration must be positive"); - } - - long fencingToken = lastFencingTokens.getOrDefault(resourceId, 0L) + 1; - lastFencingTokens.put(resourceId, fencingToken); UUID buildId = new UUID(0L, nextBuildSequence++); Build build = new Build( @@ -119,6 +132,8 @@ public synchronized Build beginBuild( now.plus(leaseDuration), now, now); + lastAllocatedVersions.put(resourceId, targetVersion); + lastFencingTokens.put(resourceId, fencingToken); builds.put(buildId, build); currentBuilds.put(resourceId, buildId); versionBuilds.put(versionKey, buildId); diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java similarity index 97% rename from src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java index f8f488b..9e9c61e 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java +++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java @@ -1,4 +1,4 @@ -package io.github.kbarseghyan.versiongate.support; +package io.github.kbarseghyan.versiongate.testkit; import io.github.kbarseghyan.versiongate.api.ErrorCode; import io.github.kbarseghyan.versiongate.api.VersionGateException; @@ -63,6 +63,7 @@ public synchronized void delete(ObjectReference objectReference) { payloads.remove(objectReference.objectKey()); } + /** Returns the number of immutable objects currently retained by this test store. */ public synchronized int size() { return payloads.size(); } diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java similarity index 76% rename from src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java index 168539e..d08be69 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java +++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java @@ -1,4 +1,4 @@ -package io.github.kbarseghyan.versiongate.support; +package io.github.kbarseghyan.versiongate.testkit; import java.time.Clock; import java.time.Duration; @@ -14,6 +14,11 @@ public final class MutableClock extends Clock { private final AtomicReference instant; private final ZoneId zone; + /** + * Creates a UTC clock at the supplied instant. + * + * @param initialInstant initial clock value + */ public MutableClock(Instant initialInstant) { this(new AtomicReference<>(Objects.requireNonNull(initialInstant)), ZoneOffset.UTC); } @@ -38,10 +43,21 @@ public Instant instant() { return instant.get(); } + /** + * Sets the current instant. + * + * @param newInstant replacement clock value + */ public void set(Instant newInstant) { instant.set(Objects.requireNonNull(newInstant)); } + /** + * Advances the clock atomically. + * + * @param duration amount to add + * @return resulting instant + */ public Instant advance(Duration duration) { Objects.requireNonNull(duration); return instant.updateAndGet(current -> current.plus(duration)); diff --git a/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java new file mode 100644 index 0000000..8d3170b --- /dev/null +++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java @@ -0,0 +1,93 @@ +package io.github.kbarseghyan.versiongate.testkit; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.github.kbarseghyan.versiongate.api.ErrorCode; +import io.github.kbarseghyan.versiongate.api.VersionGateException; +import io.github.kbarseghyan.versiongate.port.SnapshotStore; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Reusable minimum semantic contract for {@link SnapshotStore} implementations. + * + *

Adapter tests extend this class and construct an empty store for each invocation. Provider + * adapters should add integration coverage for multipart cleanup, crash recovery, and provider + * integrity guarantees. + */ +public abstract class SnapshotStoreContract { + + private SnapshotStore store; + + /** + * Creates an empty snapshot store for one contract-test invocation. + * + * @return empty store instance + */ + protected abstract SnapshotStore createSnapshotStore(); + + @BeforeEach + final void initializeContractStore() { + store = createSnapshotStore(); + } + + @Test + final void immutableReplayReturnsTheOriginalObjectAndDifferentBytesConflict() { + byte[] bytes = "snapshot".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String key = "snapshots/catalog/1/products"; + SnapshotStore.StoredObject first = store.uploadImmutable(upload(key, bytes)); + SnapshotStore.StoredObject replay = store.uploadImmutable(upload(key, bytes)); + + assertFalse(first.alreadyExisted()); + assertTrue(replay.alreadyExisted()); + assertEquals(first.reference(), replay.reference()); + + byte[] changed = bytes.clone(); + changed[0]++; + VersionGateException failure = + assertThrows(VersionGateException.class, () -> store.uploadImmutable(upload(key, changed))); + assertEquals(ErrorCode.COMPONENT_CONFLICT, failure.code()); + } + + @Test + final void storedContentCanBeVerifiedAndStreamedWithoutChangingItsIdentity() throws IOException { + byte[] bytes = "snapshot".getBytes(java.nio.charset.StandardCharsets.UTF_8); + SnapshotStore.StoredObject stored = + store.uploadImmutable(upload("snapshots/catalog/1/products", bytes)); + + store.verify(stored.reference()); + try (SnapshotStore.ObjectContent content = store.open(stored.reference())) { + assertArrayEquals(bytes, content.inputStream().readAllBytes()); + assertEquals(bytes.length, content.contentLength()); + assertEquals(sha256(bytes), content.sha256()); + } + } + + private static SnapshotStore.Upload upload(String key, byte[] bytes) { + return new SnapshotStore.Upload( + key, + new ByteArrayInputStream(bytes), + bytes.length, + "application/octet-stream", + Optional.empty(), + Optional.of(sha256(bytes))); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } +} diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java similarity index 94% rename from src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java rename to version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java index 6ce45a1..6ae0aa8 100644 --- a/src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java +++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java @@ -1,4 +1,4 @@ -package io.github.kbarseghyan.versiongate.support; +package io.github.kbarseghyan.versiongate.testkit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -71,7 +71,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except List> attempts = new ArrayList<>(); try { for (int index = 0; index < contenderCount; index++) { - long targetVersion = index + 1L; + int contender = index; attempts.add( executor.submit( () -> { @@ -79,9 +79,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except start.await(); try { return new BeginAttempt( - controlStore.beginBuild( - "catalog", targetVersion, "owner-" + targetVersion, LEASE), - null); + controlStore.beginBuild("catalog", "owner-" + contender, LEASE), null); } catch (VersionGateException exception) { return new BeginAttempt(null, exception.code()); } @@ -106,8 +104,9 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except assertThat(abandoned.status()).isEqualTo(BuildStatus.ABANDONED); assertThat(controlStore.abortBuild(winner.buildId(), winner.fencingToken())) .isSameAs(abandoned); - Build next = controlStore.beginBuild("catalog", 100, "next-owner", LEASE); + Build next = controlStore.beginBuild("catalog", "next-owner", LEASE); assertThat(next.fencingToken()).isEqualTo(winner.fencingToken() + 1); + assertThat(next.targetVersion()).isGreaterThan(winner.targetVersion()); } finally { executor.shutdownNow(); } @@ -116,7 +115,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except @Test void staleAndExpiredMutationsFailWhileFailureTerminalizationRemainsAvailable() { registerClientResource("catalog", Set.of("products")); - Build build = controlStore.beginBuild("catalog", 1, "owner", Duration.ofSeconds(10)); + Build build = controlStore.beginBuild("catalog", "owner", Duration.ofSeconds(10)); assertCode( ErrorCode.STALE_FENCING_TOKEN, @@ -151,7 +150,7 @@ void coordinatedRenewalRejectsAtomicallyOnceQuiescenceBegins() { SnapshotPolicy.COORDINATED_QUIESCE, Set.of("orders"), List.of(new Participant("database", URI.create("https://database.example")))); - Build build = controlStore.beginBuild("orders", 1, "owner", LEASE); + Build build = controlStore.beginBuild("orders", "owner", LEASE); Build quiescing = controlStore.startSnapshotPhase( build.buildId(), build.fencingToken(), BuildStatus.QUIESCING); @@ -167,7 +166,7 @@ void coordinatedRenewalRejectsAtomicallyOnceQuiescenceBegins() { @Test void completionRechecksRequiredComponentsAndKeepsItsManifestStable() { registerClientResource("catalog", Set.of("prices", "products")); - Build build = snapshottingBuild("catalog", 1); + Build build = snapshottingBuild("catalog"); SnapshotComponent products = component(build, "products", "a".repeat(64), 3); controlStore.registerSnapshotComponent(build.buildId(), build.fencingToken(), products); @@ -212,7 +211,7 @@ void completionRechecksRequiredComponentsAndKeepsItsManifestStable() { @Test void activationAtomicallyPublishesOneStableVersionAndPreservesHistory() { registerClientResource("catalog", Set.of("products")); - Build first = snapshottingBuild("catalog", 1); + Build first = snapshottingBuild("catalog"); controlStore.registerSnapshotComponent( first.buildId(), first.fencingToken(), component(first, "products", "a".repeat(64), 3)); VersionManifest firstManifest = @@ -227,7 +226,7 @@ void activationAtomicallyPublishesOneStableVersionAndPreservesHistory() { assertThat(controlStore.findActiveVersionManifest("catalog")).contains(firstManifest); assertThat(controlStore.findVersionManifest("catalog", 1)).contains(firstManifest); - Build second = snapshottingBuild("catalog", 2); + Build second = snapshottingBuild("catalog"); assertThat(second.baseActiveVersion()).isEqualTo(1); controlStore.registerSnapshotComponent( second.buildId(), second.fencingToken(), component(second, "products", "b".repeat(64), 4)); @@ -249,7 +248,7 @@ void participantProgressCanRetryAfterFailureButTerminalStatesNeverRegress() { SnapshotPolicy.COORDINATED_QUIESCE, Set.of("orders"), List.of(new Participant("database", URI.create("https://database.example")))); - Build build = controlStore.beginBuild("orders", 1, "owner", LEASE); + Build build = controlStore.beginBuild("orders", "owner", LEASE); controlStore.updateParticipantState( build.buildId(), "database", ParticipantStatus.QUIESCED, null); @@ -277,8 +276,8 @@ void participantProgressCanRetryAfterFailureButTerminalStatesNeverRegress() { void sweeperAbandonsOnlyExpiredBuildsUsingTheInjectedClock() { registerClientResource("catalog", Set.of("products")); registerClientResource("orders", Set.of("orders")); - Build expired = controlStore.beginBuild("catalog", 1, "owner", Duration.ofSeconds(10)); - Build live = controlStore.beginBuild("orders", 1, "owner", Duration.ofSeconds(30)); + Build expired = controlStore.beginBuild("catalog", "owner", Duration.ofSeconds(10)); + Build live = controlStore.beginBuild("orders", "owner", Duration.ofSeconds(30)); clock.advance(Duration.ofSeconds(10)); assertThat(controlStore.abandonExpiredBuilds()).isOne(); @@ -393,7 +392,7 @@ void applicationFlowUsesTheReusableStoresWithoutBypassingStreamingOrVisibility() new VersionGateService.RegisterResourceCommand( "catalog", SnapshotPolicy.CLIENT_MANAGED, Set.of("products"), List.of())); Build build = - service.beginBuild(new VersionGateService.BeginBuildCommand("catalog", 1, "owner", LEASE)); + service.beginBuild(new VersionGateService.BeginBuildCommand("catalog", "owner", LEASE)); service.startSnapshotPhase( new VersionGateService.BuildTokenCommand(build.buildId(), build.fencingToken())); byte[] bytes = "streamed-snapshot".getBytes(StandardCharsets.UTF_8); @@ -427,8 +426,8 @@ private void registerClientResource(String resourceId, Set components) { controlStore.registerResource(resourceId, SnapshotPolicy.CLIENT_MANAGED, components, List.of()); } - private Build snapshottingBuild(String resourceId, long version) { - Build build = controlStore.beginBuild(resourceId, version, "owner", LEASE); + private Build snapshottingBuild(String resourceId) { + Build build = controlStore.beginBuild(resourceId, "owner", LEASE); return controlStore.startSnapshotPhase( build.buildId(), build.fencingToken(), BuildStatus.SNAPSHOTTING); } diff --git a/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java new file mode 100644 index 0000000..daaed7c --- /dev/null +++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java @@ -0,0 +1,12 @@ +package io.github.kbarseghyan.versiongate.testkit; + +import io.github.kbarseghyan.versiongate.port.ControlStore; +import java.time.Clock; + +final class InMemoryControlStoreContractTest extends ControlStoreContract { + + @Override + protected ControlStore createControlStore(Clock clock) { + return new InMemoryControlStore(clock); + } +} diff --git a/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java new file mode 100644 index 0000000..b49f8c9 --- /dev/null +++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java @@ -0,0 +1,11 @@ +package io.github.kbarseghyan.versiongate.testkit; + +import io.github.kbarseghyan.versiongate.port.SnapshotStore; + +final class InMemorySnapshotStoreContractTest extends SnapshotStoreContract { + + @Override + protected SnapshotStore createSnapshotStore() { + return new InMemorySnapshotStore(); + } +}