diff --git a/.checkstyle/checkstyle-suppressions.xml b/.checkstyle/checkstyle-suppressions.xml new file mode 100644 index 0000000..063764e --- /dev/null +++ b/.checkstyle/checkstyle-suppressions.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.checkstyle/checkstyle.xml b/.checkstyle/checkstyle.xml new file mode 100644 index 0000000..9b2cc54 --- /dev/null +++ b/.checkstyle/checkstyle.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cd7a907 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: Citymonstret +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: [ 'https://paypal.me/Sauilitired' ]# Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1a64867 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build disruptor +on: + push: + branches: [ "**" ] + tags-ignore: [ "**" ] + pull_request: + release: + types: [ published ] +jobs: + build: + # Only run on PRs if the source branch is on someone else's repo + if: ${{ github.event_name != 'pull_request' || github.repository != github.event.pull_request.head.repo.full_name }} + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v4 + - uses: gradle/wrapper-validation-action@v3 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 + with: + # allow master and *-dev branches to write caches (default is only master/main) + cache-read-only: ${{ github.ref != 'refs/heads/master' && !(endsWith(github.ref, '-dev') && startsWith(github.ref, 'refs/heads/')) }} + - name: Build + run: ./gradlew build + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: Test Results + path: | + **/build/test-results/test/TEST-*.xml + - name: Determine Status + run: | + if [ "$(./gradlew properties | awk '/^version:/ { print $2; }' | grep '\-SNAPSHOT')" ]; then + echo "STATUS=snapshot" >> $GITHUB_ENV + else + echo "STATUS=release" >> $GITHUB_ENV + fi + - name: Publish Snapshot + if: "${{ env.STATUS != 'release' && github.event_name == 'push' && github.ref == 'refs/heads/master' }}" + run: ./gradlew publish + env: + ORG_GRADLE_PROJECT_sonatypeUsername: "${{ secrets.SONATYPE_USERNAME }}" + ORG_GRADLE_PROJECT_sonatypePassword: "${{ secrets.SONATYPE_PASSWORD }}" + - name: Publish Release + if: "${{ env.STATUS == 'release' && github.event_name == 'release' }}" + run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository + env: + ORG_GRADLE_PROJECT_sonatypeUsername: "${{ secrets.SONATYPE_USERNAME }}" + ORG_GRADLE_PROJECT_sonatypePassword: "${{ secrets.SONATYPE_PASSWORD }}" + ORG_GRADLE_PROJECT_signingKey: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_signingPassword: "${{ secrets.SIGNING_PASSWORD }}" + event_file: + name: "Event File" + # Only run on PRs if the source branch is on someone else's repo + if: ${{ github.event_name != 'pull_request' || github.repository != github.event.pull_request.head.repo.full_name }} + runs-on: ubuntu-latest + steps: + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: Event File + path: ${{ github.event_path }} \ No newline at end of file diff --git a/.github/workflows/test_results.yml b/.github/workflows/test_results.yml new file mode 100644 index 0000000..f16d7a4 --- /dev/null +++ b/.github/workflows/test_results.yml @@ -0,0 +1,33 @@ +name: Test Results + +on: + workflow_run: + workflows: [ "Build disruptor" ] + types: + - completed +permissions: { } + +jobs: + test-results: + name: Test Results + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion != 'skipped' + permissions: + checks: write + # needed unless run with comment_mode: off + pull-requests: write + # required by download step to access artifacts API + actions: read + steps: + - name: Download and Extract Artifacts + uses: dawidd6/action-download-artifact@v6 + with: + run_id: ${{ github.event.workflow_run.id }} + path: artifacts + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + commit: ${{ github.event.workflow_run.head_sha }} + event_file: artifacts/Event File/event.json + event_name: ${{ github.event.workflow_run.event }} + files: "artifacts/**/*.xml" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..465281b --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Created by https://www.toptal.com/developers/gitignore/api/intellij+all,java,gradle,git +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all,java,gradle,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/intellij+all,java,gradle,git \ No newline at end of file diff --git a/.spotless/disruptor.importorder b/.spotless/disruptor.importorder new file mode 100644 index 0000000..f84b813 --- /dev/null +++ b/.spotless/disruptor.importorder @@ -0,0 +1,3 @@ +# disruptor import order +0= +1=\# \ No newline at end of file diff --git a/HEADER b/HEADER new file mode 100644 index 0000000..51ce1f2 --- /dev/null +++ b/HEADER @@ -0,0 +1,23 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9335dcf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Incendo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2af0f8a --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +
+

disruptor

+ +![license](https://img.shields.io/github/license/incendo/disruptor.svg) +![build](https://img.shields.io/github/actions/workflow/status/incendo/disruptor/build.yml?logo=github) +
+ +library for introducing disruptions to your code to replicate an unstable live environment. + +## Modules + +- **core:** core disruptor API +- ~~**spring:** spring integration~~ +- ~~**feign:** feign integration~~ + +## Example + +### Pure Java + +```java +final Disruptor disruptor = Disruptor.builder() + .group( + // Creates a new group called "test"... + "test", + group -> group + .config( + // which triggers 25% of the time, and lasts for 5 seconds once triggered + DisruptionTrigger.random(0.25f).lasting(Duration.ofSeconds(5L)), + config -> config + // running before the method that is disrupted + .mode(DisruptionMode.BEFORE) + // introducing a delay of 5 seconds + .delay(Duration.ofSeconds(5L)) + // then throwing an exception + .throwException(ctx -> new RuntimeException("hello :)")) + ) + ) + .build(); + +disruptor.disruptWithoutResult("test", () -> { + System.out.println("test"); +}); +``` \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e1445f0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# TODO + +1. [x] Create core API +2. [x] Tests :) +3. [ ] Create Spring (AOP) integration +4. [ ] Create Feign integration \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d6b61f8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.cloud.buildLogic.rootProject.publishing) + alias(libs.plugins.cloud.buildLogic.rootProject.spotless) +} + +spotlessPredeclare { + kotlin { ktlint(libs.versions.ktlint.get()) } + kotlinGradle { ktlint(libs.versions.ktlint.get()) } +} + +subprojects { + afterEvaluate { + tasks.withType().configureEach { + options.compilerArgs.remove("-Werror") + } + } +} + +tasks { + spotlessCheck { + dependsOn(gradle.includedBuild("build-logic").task(":spotlessCheck") ) + } + spotlessApply { + dependsOn(gradle.includedBuild("build-logic").task(":spotlessApply")) + } +} \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..24c8e62 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("disruptor.base-conventions") + id("disruptor.publishing-conventions") +} + +dependencies { + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform) +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptionConfig.java b/core/src/main/java/org/incendo/disruptor/DisruptionConfig.java new file mode 100644 index 0000000..fc3d198 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptionConfig.java @@ -0,0 +1,160 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import org.apiguardian.api.API; +import org.incendo.disruptor.disruption.Disruption; +import org.incendo.disruptor.trigger.DisruptionTrigger; + +@API(status = API.Status.STABLE, since = "1.0.0") +public interface DisruptionConfig { + + /** + * Returns a new mutable {@link DisruptionConfig} builder. + * + * @return the builder + */ + static Builder builder() { + return new Builder(); + } + + /** + * Returns the disruption trigger. + * + * @return the trigger + */ + DisruptionTrigger trigger(); + + /** + * Return the disruptions, in the order they will be invoked. + * + * @return the disruptions + */ + List disruptions(); + + /** + * Returns the disruption mode. + * + * @return the mode + */ + DisruptionMode mode(); + + @API(status = API.Status.STABLE, since = "1.0.0") + final class Builder { + + private final List disruptions = new ArrayList<>(); + private DisruptionTrigger trigger = DisruptionTrigger.never(); + private DisruptionMode mode = DisruptionMode.BEFORE; + + private Builder() { + } + + /** + * Sets the trigger. + * + * @param trigger new trigger + * @return {@code this} + */ + public Builder trigger(final DisruptionTrigger trigger) { + this.trigger = Objects.requireNonNull(trigger, "trigger"); + return this; + } + + /** + * Adds the given {@code disruptions}. + * + * @param disruptions disruptions to add + * @return {@code this} + */ + public Builder disruptions(final Disruption... disruptions) { + Objects.requireNonNull(disruptions, "disruptions"); + for (final Disruption disruption : disruptions) { + Objects.requireNonNull(disruption, "disruption"); + } + this.disruptions.addAll(Arrays.asList(disruptions)); + return this; + } + + /** + * Adds the given {@code disruptions}. + * + * @param disruptions disruptions to add + * @return {@code this} + */ + public Builder disruptions(final List disruptions) { + Objects.requireNonNull(disruptions, "disruptions"); + for (final Disruption disruption : disruptions) { + Objects.requireNonNull(disruption, "disruption"); + } + this.disruptions.addAll(disruptions); + return this; + } + + /** + * Adds a {@link Disruption#delaying(Duration)} disruption. + * + * @param duration duration to delay for + * @return {@code this} + */ + public Builder delay(final Duration duration) { + return this.disruptions(Disruption.delaying(duration)); + } + + /** + * Adds a {@link Disruption#throwing(Function)} disruption. + * + * @param generator throwable generator + * @return {@code this} + */ + public Builder throwException(final Function generator) { + return this.disruptions(Disruption.throwing(generator)); + } + + /** + * Sets the disruption mode to the given {@code mode}. + * + * @param mode new mode + * @return {@code this} + */ + public Builder mode(final DisruptionMode mode) { + this.mode = Objects.requireNonNull(mode, "mode"); + return this; + } + + /** + * Build a new {@link DisruptionConfig} instance using {@code this} builder. + * + * @return the config instance + */ + public DisruptionConfig build() { + return new DisruptionConfigImpl(this.trigger, List.copyOf(this.disruptions), this.mode); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptionConfigImpl.java b/core/src/main/java/org/incendo/disruptor/DisruptionConfigImpl.java new file mode 100644 index 0000000..57a4e9e --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptionConfigImpl.java @@ -0,0 +1,38 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.List; +import org.apiguardian.api.API; +import org.incendo.disruptor.disruption.Disruption; +import org.incendo.disruptor.trigger.DisruptionTrigger; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +record DisruptionConfigImpl( + DisruptionTrigger trigger, + List disruptions, + DisruptionMode mode +) implements DisruptionConfig { + +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptionException.java b/core/src/main/java/org/incendo/disruptor/DisruptionException.java new file mode 100644 index 0000000..4216c23 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptionException.java @@ -0,0 +1,40 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +/** + * Exception that wraps exceptions thrown by a {@link org.incendo.disruptor.disruption.Disruption} + * if the original exception was not a runtime exception. + */ +public final class DisruptionException extends RuntimeException { + + /** + * Creates a new disruption exception. + * + * @param cause cause of the exception + */ + public DisruptionException(final Throwable cause) { + super(cause); + } +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptionMode.java b/core/src/main/java/org/incendo/disruptor/DisruptionMode.java new file mode 100644 index 0000000..7183eda --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptionMode.java @@ -0,0 +1,38 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import org.apiguardian.api.API; + +@API(status = API.Status.STABLE, since = "1.0.0") +public enum DisruptionMode { + /** + * The disruption takes place before the invocation. + */ + BEFORE, + /** + * The disruption takes place after the invocation. + */ + AFTER +} diff --git a/core/src/main/java/org/incendo/disruptor/Disruptor.java b/core/src/main/java/org/incendo/disruptor/Disruptor.java new file mode 100644 index 0000000..09a54a7 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/Disruptor.java @@ -0,0 +1,184 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.apiguardian.api.API; + +/** + * The disruptor contains the configuration used by the Incendo Disruptor library. + * The configuration is immutable and may not be modified once it has been constructed. + * + *

Note: This interface should not be implemented. An instance should be built using {@link #builder()}.

+ * + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public interface Disruptor { + + /** + * Creates a new {@link Disruptor} builder. The builder is mutable. + * + * @return a mutable builder + */ + static Builder builder() { + return new Builder(); + } + + /** + * Return an empty disruptor instance with no groups configured. + * + * @return empty instance + */ + static Disruptor empty() { + return DisruptorImpl.empty(); + } + + /** + * Returns the group identified by the given {@code name}, if it exists + * + * @param name group name + * @return optional that contain the group if it exists + */ + Optional group(String name); + + /** + * Runs the given {@code supplier}, invoking any relevant disruptions before and after. + * If a disruption throws an exception, it'll be propagated and the execution will terminate. + * + * @param group disruptor group + * @param supplier result supplier + * @return the result + * @param result type + */ + default T disrupt(final String group, Supplier supplier) { + final DisruptorGroup disruptorGroup = this.group(group).orElse(null); + if (disruptorGroup == null) { + return supplier.get(); + } + + final DisruptorContext context = DisruptorContext.of(group); + + this.triggerDisruptions(context, disruptorGroup, DisruptionMode.BEFORE); + final T result = supplier.get(); + this.triggerDisruptions(context, disruptorGroup, DisruptionMode.AFTER); + return result; + } + + /** + * Like {@link #disrupt(String, Supplier)} but without returning a result. + * + * @param group disruption group + * @param runnable runnable to wrap + */ + default void disruptWithoutResult(final String group, Runnable runnable) { + this.disrupt(group, (Supplier) () -> { + runnable.run(); + return runnable; + }); + } + + private void triggerDisruptions( + final DisruptorContext context, + final DisruptorGroup group, + final DisruptionMode mode + ) { + group.configurations() + .stream() + .filter(config -> config.mode() == mode) + .filter(config -> config.trigger().shouldTrigger(context)) + .flatMap(config -> config.disruptions().stream()) + .forEach(disruption -> disruption.trigger(context)); + } + + /** + * Builder for {@link Disruptor} instances. The builder should be constructed using {@link #builder()}. + * + * @since 1.0.0 + */ + @API(status = API.Status.STABLE, since = "1.0.0") + final class Builder { + + private final Map groups = new HashMap<>(); + + private Builder() { + } + + /** + * Adds the given {@code group} with the given {@code name} to the disruptor instance. + * + * @param name group name + * @param group group + * @return {@code this} + */ + public Builder group( + final String name, + final DisruptorGroup group + ) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(group, "group"); + + this.groups.put(name, group); + + return this; + } + + /** + * Adds the disruptor group with the given {@code name} to the disruptor instance, + * after letting the {@code decorator} decorate the group builder. + * + * @param name group name + * @param decorator group decorator + * @return {@code this} + */ + public Builder group( + final String name, + final Consumer decorator + ) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(decorator, "decorator"); + + final DisruptorGroup.Builder builder = DisruptorGroup.builder(); + decorator.accept(builder); + + return this.group(name, builder.build()); + } + + /** + * Build a new {@link Disruptor} instance using {@code this} builder. + * + * @return the disruptor instance + */ + public Disruptor build() { + return new DisruptorImpl( + Map.copyOf(this.groups) + ); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptorContext.java b/core/src/main/java/org/incendo/disruptor/DisruptorContext.java new file mode 100644 index 0000000..d593a3f --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptorContext.java @@ -0,0 +1,49 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.Objects; +import org.apiguardian.api.API; + +@API(status = API.Status.STABLE, since = "1.0.0") +public interface DisruptorContext { + + /** + * Returns a new {@link DisruptorContext}. + * + * @param group group + * @return the context instance + */ + static DisruptorContext of(final String group) { + Objects.requireNonNull(group, "group"); + return new DisruptorContextImpl(group); + } + + /** + * Returns the group that the context was created for. + * + * @return disruptor group name + */ + String group(); +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptorContextImpl.java b/core/src/main/java/org/incendo/disruptor/DisruptorContextImpl.java new file mode 100644 index 0000000..46a279e --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptorContextImpl.java @@ -0,0 +1,33 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import org.apiguardian.api.API; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +record DisruptorContextImpl( + String group +) implements DisruptorContext { + +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptorGroup.java b/core/src/main/java/org/incendo/disruptor/DisruptorGroup.java new file mode 100644 index 0000000..1badda4 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptorGroup.java @@ -0,0 +1,125 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import org.apiguardian.api.API; +import org.incendo.disruptor.trigger.DisruptionTrigger; + +/** + * A disruptor configuration group. + * + *

Note: This interface should not be implemented. An instance should be built using {@link #builder()}.

+ + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public interface DisruptorGroup { + + /** + * Creates a new {@link DisruptorGroup} builder. The builder is mutable. + * + * @return a mutable builder + */ + static Builder builder() { + return new Builder(); + } + + /** + * Returns the disruption configurations. + * + * @return the configurations + */ + List configurations(); + + @API(status = API.Status.STABLE, since = "1.0.0") + final class Builder { + + private final List configurations = new ArrayList<>(); + + private Builder() { + } + + /** + * Adds the given {@code config} to the group. + * + * @param config the config + * @return {@code this} + */ + public Builder config(final DisruptionConfig config) { + Objects.requireNonNull(config, "config"); + this.configurations.add(config); + return this; + } + + /** + * Adds the given {@code config} to the group. + * + * @param config the config + * @return {@code this} + */ + public Builder config(final DisruptionConfig.Builder config) { + return this.config(config.build()); + } + + /** + * Adds a config to the group after letting the given {@code decorator} decorate the config builder. + * + * @param decorator the decorator + * @return {@code this} + */ + public Builder config(final Consumer decorator) { + Objects.requireNonNull(decorator, "config"); + final DisruptionConfig.Builder builder = DisruptionConfig.builder(); + decorator.accept(builder); + return this.config(builder); + } + + /** + * Adds a config to the group after letting the given {@code decorator} decorate the config builder. + * + * @param trigger the disruption trigger + * @param decorator the decorator + * @return {@code this} + */ + public Builder config(final DisruptionTrigger trigger, final Consumer decorator) { + Objects.requireNonNull(decorator, "config"); + final DisruptionConfig.Builder builder = DisruptionConfig.builder().trigger(trigger); + decorator.accept(builder); + return this.config(builder); + } + + /** + * Build a new {@link DisruptorGroup} instance using {@code this} builder. + * + * @return the disruptor group instance + */ + public DisruptorGroup build() { + return new DisruptorGroupImpl(List.copyOf(this.configurations)); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptorGroupImpl.java b/core/src/main/java/org/incendo/disruptor/DisruptorGroupImpl.java new file mode 100644 index 0000000..1003bfa --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptorGroupImpl.java @@ -0,0 +1,40 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.List; +import org.apiguardian.api.API; + +/** + * The main {@link DisruptorGroup} implementation. + * + * @param configurations group configurations + * @since 1.0.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0.0") +record DisruptorGroupImpl( + List configurations +) implements DisruptorGroup { + +} diff --git a/core/src/main/java/org/incendo/disruptor/DisruptorImpl.java b/core/src/main/java/org/incendo/disruptor/DisruptorImpl.java new file mode 100644 index 0000000..858b481 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/DisruptorImpl.java @@ -0,0 +1,53 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apiguardian.api.API; + +/** + * The main {@link Disruptor} implementation. + * + * @param groups configured groups + * @since 1.0.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0.0") +record DisruptorImpl( + Map groups +) implements Disruptor { + + private static DisruptorImpl empty = new DisruptorImpl(Map.of()); + + static Disruptor empty() { + return empty; + } + + @Override + public Optional group(final String name) { + Objects.requireNonNull(name, "name"); + return Optional.ofNullable(this.groups.get(name)); + } +} diff --git a/core/src/main/java/org/incendo/disruptor/disruption/Delay.java b/core/src/main/java/org/incendo/disruptor/disruption/Delay.java new file mode 100644 index 0000000..282a503 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/disruption/Delay.java @@ -0,0 +1,47 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.disruption; + +import java.time.Duration; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptionException; +import org.incendo.disruptor.DisruptorContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +record Delay(Duration duration) implements Disruption { + + private static final Logger LOGGER = LoggerFactory.getLogger(Delay.class); + + @Override + public void trigger(final DisruptorContext context) { + LOGGER.debug("Starting delay of {}ms for group {}", this.duration.toMillis(), context.group()); + try { + Thread.sleep(this.duration); + } catch (final InterruptedException e) { + throw new DisruptionException(e); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/disruption/Disruption.java b/core/src/main/java/org/incendo/disruptor/disruption/Disruption.java new file mode 100644 index 0000000..4c02b2a --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/disruption/Disruption.java @@ -0,0 +1,71 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.disruption; + +import java.time.Duration; +import java.util.Objects; +import java.util.function.Function; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; + +/** + * Disruption of an invocation. + * + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public interface Disruption { + + /** + * Returns a disruption that delays the invocation by the given {@code duration}. + * + * @param duration delay duration + * @return the disruption + */ + static Disruption delaying(final Duration duration) { + Objects.requireNonNull(duration, "duration"); + return new Delay(duration); + } + + /** + * Returns a disruption that throws the exception generated by the given function. + * + *

If the {@code generator} generates a non-{@link RuntimeException} then the exception will be wrapped in a + * {@link org.incendo.disruptor.DisruptionException}.

+ * + * @param generator function that generates the throwable + * @return the disruption + */ + static Disruption throwing(final Function generator) { + Objects.requireNonNull(generator, "throwable"); + return new Throwing(generator); + } + + /** + * Triggers the disruption. + * + * @param context disruption context + */ + void trigger(DisruptorContext context); +} diff --git a/core/src/main/java/org/incendo/disruptor/disruption/Throwing.java b/core/src/main/java/org/incendo/disruptor/disruption/Throwing.java new file mode 100644 index 0000000..ba85dd3 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/disruption/Throwing.java @@ -0,0 +1,42 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.disruption; + +import java.util.function.Function; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptionException; +import org.incendo.disruptor.DisruptorContext; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +record Throwing(Function throwableSupplier) implements Disruption { + + @Override + public void trigger(final DisruptorContext context) { + final Throwable throwable = this.throwableSupplier.apply(context); + if (throwable instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new DisruptionException(throwable); + } +} diff --git a/core/src/main/java/org/incendo/disruptor/disruption/package-info.java b/core/src/main/java/org/incendo/disruptor/disruption/package-info.java new file mode 100644 index 0000000..a0113f2 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/disruption/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.incendo.disruptor.disruption; + +import org.jspecify.annotations.NullMarked; diff --git a/core/src/main/java/org/incendo/disruptor/package-info.java b/core/src/main/java/org/incendo/disruptor/package-info.java new file mode 100644 index 0000000..0cae6da --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.incendo.disruptor; + +import org.jspecify.annotations.NullMarked; diff --git a/core/src/main/java/org/incendo/disruptor/trigger/CountingTrigger.java b/core/src/main/java/org/incendo/disruptor/trigger/CountingTrigger.java new file mode 100644 index 0000000..8e38ec3 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/CountingTrigger.java @@ -0,0 +1,54 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; + +@API(status = API.Status.STABLE, since = "1.0.0") +final class CountingTrigger implements DisruptionTrigger { + + private final Lock lock = new ReentrantLock(); + + private final int target; + private int invocations; + + CountingTrigger(final int target) { + this.target = target; + this.invocations = 0; + } + + @Override + public boolean shouldTrigger(final DisruptorContext context) { + this.lock.lock(); + try { + this.invocations = (this.invocations + 1) % this.target; + return this.invocations == 0; + } finally { + this.lock.unlock(); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/trigger/DisruptionTrigger.java b/core/src/main/java/org/incendo/disruptor/trigger/DisruptionTrigger.java new file mode 100644 index 0000000..3318896 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/DisruptionTrigger.java @@ -0,0 +1,88 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import java.time.Duration; +import java.util.Objects; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; + +/** + * Trigger that determines whether a disruption should be actived. + * + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public interface DisruptionTrigger { + + /** + * Returns a trigger that activates every time the number of invocations reaches {@code targetInvocations}, resetting + * each time it does. + * + * @param targetInvocations target invocation count + * @return the trigger + */ + static DisruptionTrigger counting(final int targetInvocations) { + return new CountingTrigger(targetInvocations); + } + + /** + * Returns a trigger that activates randomly based on the given {@code chance} in the range 0 to 1. + * + * @param chance chance in the range [0, 1] + * @return the trigger + */ + static DisruptionTrigger random(final float chance) { + return new RandomTrigger(chance); + } + + /** + * Returns a trigger that never activates. + * + * @return the trigger + */ + static DisruptionTrigger never() { + return new NeverTrigger(); + } + + /** + * Returns whether the disruption should trigger. + * + * @param context current context + * @return {@code true} if the disruption should trigger, else {@code false} + */ + boolean shouldTrigger(DisruptorContext context); + + /** + * Returns a variant of {@code this} trigger that will remember the activation + * status for a period of time after {@code this} trigger has activated. + * + * @param duration duration the trigger should be active for + * @return the lasting trigger + */ + default DisruptionTrigger lasting(final Duration duration) { + Objects.requireNonNull(duration, "duration"); + return new LastingTrigger(duration, this); + } +} diff --git a/core/src/main/java/org/incendo/disruptor/trigger/LastingTrigger.java b/core/src/main/java/org/incendo/disruptor/trigger/LastingTrigger.java new file mode 100644 index 0000000..e83d44c --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/LastingTrigger.java @@ -0,0 +1,70 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +final class LastingTrigger implements DisruptionTrigger { + + private static final Logger LOGGER = LoggerFactory.getLogger(LastingTrigger.class); + + private final Lock lock = new ReentrantLock(); + private final Duration duration; + private final DisruptionTrigger trigger; + + private Instant disruptionEnd; + + LastingTrigger(final Duration duration, final DisruptionTrigger trigger) { + this.duration = Objects.requireNonNull(duration, "duration"); + this.trigger = Objects.requireNonNull(trigger, "trigger"); + this.disruptionEnd = Instant.EPOCH; + } + + @Override + public boolean shouldTrigger(final DisruptorContext context) { + this.lock.lock(); + try { + if (Instant.now().isBefore(this.disruptionEnd)) { + return true; + } + if (!this.trigger.shouldTrigger(context)) { + return false; + } + this.disruptionEnd = Instant.now().plus(this.duration); + LOGGER.info("Lasting disruption for group {} started and will end at {}", context.group(), this.disruptionEnd); + return true; + } finally { + this.lock.unlock(); + } + } +} diff --git a/core/src/main/java/org/incendo/disruptor/trigger/NeverTrigger.java b/core/src/main/java/org/incendo/disruptor/trigger/NeverTrigger.java new file mode 100644 index 0000000..bd7f1cf --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/NeverTrigger.java @@ -0,0 +1,36 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +record NeverTrigger() implements DisruptionTrigger { + + @Override + public boolean shouldTrigger(final DisruptorContext context) { + return false; + } +} diff --git a/core/src/main/java/org/incendo/disruptor/trigger/RandomTrigger.java b/core/src/main/java/org/incendo/disruptor/trigger/RandomTrigger.java new file mode 100644 index 0000000..07769f5 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/RandomTrigger.java @@ -0,0 +1,42 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import org.apiguardian.api.API; +import org.incendo.disruptor.DisruptorContext; + +/** + * Trigger that will randomly activate disruptions. + * + * @param chance chance of the trigger activating in the range 0 to 1 + * @since 1.0.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0.0") +record RandomTrigger(float chance) implements DisruptionTrigger { + + @Override + public boolean shouldTrigger(final DisruptorContext context) { + return Math.random() <= this.chance; + } +} diff --git a/core/src/main/java/org/incendo/disruptor/trigger/package-info.java b/core/src/main/java/org/incendo/disruptor/trigger/package-info.java new file mode 100644 index 0000000..7d3bdf0 --- /dev/null +++ b/core/src/main/java/org/incendo/disruptor/trigger/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.incendo.disruptor.trigger; + +import org.jspecify.annotations.NullMarked; diff --git a/core/src/test/java/org/incendo/disruptor/DisruptorIntegrationTest.java b/core/src/test/java/org/incendo/disruptor/DisruptorIntegrationTest.java new file mode 100644 index 0000000..0ea2011 --- /dev/null +++ b/core/src/test/java/org/incendo/disruptor/DisruptorIntegrationTest.java @@ -0,0 +1,83 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor; + +import org.incendo.disruptor.trigger.DisruptionTrigger; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DisruptorIntegrationTest { + + @Test + void exceptionPropagation() { + // Arrange + final Disruptor disruptor = Disruptor.builder() + .group( + "test", + group -> group.config(config -> config.trigger(new AlwaysTrigger()) + .throwException(ctx -> new RuntimeException("test")) + ) + ) + .build(); + + // Act & Assert + final RuntimeException exception = assertThrows( + RuntimeException.class, + () -> disruptor.disruptWithoutResult("test", () -> {}) + ); + assertThat(exception).hasMessageThat().isEqualTo("test"); + } + + @Test + void resultPropagation() { + // Arrange + final Disruptor disruptor = Disruptor.builder() + .group( + "test", + group -> group.config(config -> config.trigger(DisruptionTrigger.never()) + .throwException(ctx -> new RuntimeException("test")) + ) + ) + .build(); + + // Act + final String result = disruptor.disrupt( + "test", + () -> "hello world" + ); + + // Assert + assertThat(result).isEqualTo("hello world"); + } + + private static final class AlwaysTrigger implements DisruptionTrigger { + + @Override + public boolean shouldTrigger(final DisruptorContext context) { + return true; + } + } +} diff --git a/core/src/test/java/org/incendo/disruptor/disruption/ThrowingTest.java b/core/src/test/java/org/incendo/disruptor/disruption/ThrowingTest.java new file mode 100644 index 0000000..c7620e2 --- /dev/null +++ b/core/src/test/java/org/incendo/disruptor/disruption/ThrowingTest.java @@ -0,0 +1,62 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.disruption; + +import org.incendo.disruptor.DisruptionException; +import org.incendo.disruptor.DisruptorContext; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ThrowingTest { + + @Test + void Trigger_RuntimeException_ForwardsException() { + // Arrange + final Exception exception = new RuntimeException(); + final Disruption disruption = Disruption.throwing(ctx -> exception); + + // Act & Assert + final Exception result = assertThrows( + RuntimeException.class, + () -> disruption.trigger(DisruptorContext.of("group")) + ); + assertThat(result).isEqualTo(exception); + } + + @Test + void Trigger_Throwable_WrapsException() { + // Arrange + final Throwable throwable = new Throwable(); + final Disruption disruption = Disruption.throwing(ctx -> throwable); + + // Act & Assert + final Exception result = assertThrows( + DisruptionException.class, + () -> disruption.trigger(DisruptorContext.of("group")) + ); + assertThat(result).hasCauseThat().isEqualTo(throwable); + } +} diff --git a/core/src/test/java/org/incendo/disruptor/trigger/CountingTriggerTest.java b/core/src/test/java/org/incendo/disruptor/trigger/CountingTriggerTest.java new file mode 100644 index 0000000..9124247 --- /dev/null +++ b/core/src/test/java/org/incendo/disruptor/trigger/CountingTriggerTest.java @@ -0,0 +1,45 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import org.incendo.disruptor.DisruptorContext; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; + +class CountingTriggerTest { + + @Test + void ShouldTrigger_3RequiredInvocations_TriggersAfter3Invocations() { + // Arrange + final DisruptionTrigger trigger = DisruptionTrigger.counting(3 /* targetInvocations */); + final DisruptorContext context = DisruptorContext.of("test" /* group */); + + // Act & Assert + assertThat(trigger.shouldTrigger(context)).isFalse(); + assertThat(trigger.shouldTrigger(context)).isFalse(); + assertThat(trigger.shouldTrigger(context)).isTrue(); + assertThat(trigger.shouldTrigger(context)).isFalse(); + } +} diff --git a/core/src/test/java/org/incendo/disruptor/trigger/LastingTriggerTest.java b/core/src/test/java/org/incendo/disruptor/trigger/LastingTriggerTest.java new file mode 100644 index 0000000..752f83a --- /dev/null +++ b/core/src/test/java/org/incendo/disruptor/trigger/LastingTriggerTest.java @@ -0,0 +1,50 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.disruptor.trigger; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.incendo.disruptor.DisruptorContext; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; + +class LastingTriggerTest { + + @Test + void ShouldTrigger_OnceTrigger_LastsForDuration() { + // Arrange + final AtomicBoolean triggered = new AtomicBoolean(false); + final DisruptionTrigger baseTrigger = context -> triggered.get(); + final DisruptionTrigger trigger = baseTrigger.lasting(Duration.ofMinutes(5L)); + final DisruptorContext context = DisruptorContext.of("test" /* group */); + + // Act & Assert + assertThat(trigger.shouldTrigger(context)).isFalse(); + triggered.set(true); + assertThat(trigger.shouldTrigger(context)).isTrue(); + triggered.set(false); + assertThat(trigger.shouldTrigger(context)).isTrue(); + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9a40796 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +group="org.incendo" +version="0.0.1-SNAPSHOT" +description="Disruptor" + +org.gradle.configureondemand=true +org.gradle.caching=true +org.gradle.parallel=true \ No newline at end of file diff --git a/gradle/build-logic/build.gradle.kts b/gradle/build-logic/build.gradle.kts new file mode 100644 index 0000000..9c72d9e --- /dev/null +++ b/gradle/build-logic/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + `kotlin-dsl` + alias(libs.plugins.cloud.buildLogic.spotless) +} + +repositories { + gradlePluginPortal() + maven("https://oss.sonatype.org/content/repositories/snapshots/") { + name = "sonatypeOssSnapshots" + mavenContent { + snapshotsOnly() + } + } +} + +dependencies { + implementation(libs.cloud.build.logic) + implementation(libs.gradleKotlinJvm) + + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) +} + +cloudSpotless { + licenseHeaderFile.convention(null as RegularFile?) + ktlintVersion = libs.versions.ktlint +} diff --git a/gradle/build-logic/settings.gradle.kts b/gradle/build-logic/settings.gradle.kts new file mode 100644 index 0000000..9760650 --- /dev/null +++ b/gradle/build-logic/settings.gradle.kts @@ -0,0 +1,21 @@ +rootProject.name = "build-logic" + +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://oss.sonatype.org/content/repositories/snapshots/") { + name = "sonatypeOssSnapshots" + mavenContent { + snapshotsOnly() + } + } + } +} + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../libs.versions.toml")) + } + } +} diff --git a/gradle/build-logic/src/main/kotlin/disruptor.base-conventions.gradle.kts b/gradle/build-logic/src/main/kotlin/disruptor.base-conventions.gradle.kts new file mode 100644 index 0000000..68b9310 --- /dev/null +++ b/gradle/build-logic/src/main/kotlin/disruptor.base-conventions.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("org.incendo.cloud-build-logic") + id("org.incendo.cloud-build-logic.spotless") + `java-library` +} + +indra { + javaVersions { + minimumToolchain(21) + target(21) + testWith().set(setOf(21)) + } + checkstyle().set(libs.versions.checkstyle) +} + +cloudSpotless { + ktlintVersion = libs.versions.ktlint +} + +spotless { + java { + importOrderFile(rootProject.file(".spotless/disruptor.importorder")) + } +} + +dependencies { + // Common dependencies. + api(libs.slf4j) + api(libs.jspecify) + api(libs.apiguardian) + + // Test dependencies + testImplementation(libs.truth) +} diff --git a/gradle/build-logic/src/main/kotlin/disruptor.publishing-conventions.gradle.kts b/gradle/build-logic/src/main/kotlin/disruptor.publishing-conventions.gradle.kts new file mode 100644 index 0000000..e746dac --- /dev/null +++ b/gradle/build-logic/src/main/kotlin/disruptor.publishing-conventions.gradle.kts @@ -0,0 +1,20 @@ +import org.incendo.cloudbuildlogic.city + +plugins { + id("org.incendo.cloud-build-logic.publishing") +} + +indra { + github("Incendo", "disruptor") { + ci(true) + } + mitLicense() + + configurePublications { + pom { + developers { + city() + } + } + } +} diff --git a/gradle/build-logic/src/main/kotlin/extensions.kt b/gradle/build-logic/src/main/kotlin/extensions.kt new file mode 100644 index 0000000..12ae20b --- /dev/null +++ b/gradle/build-logic/src/main/kotlin/extensions.kt @@ -0,0 +1,6 @@ +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.kotlin.dsl.the + +val Project.libs: LibrariesForLibs + get() = the() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..6b88785 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,41 @@ +[plugins] +spring-plugin-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } +cloud-buildLogic-spotless = { id = "org.incendo.cloud-build-logic.spotless", version.ref = "cloud-build-logic" } +cloud-buildLogic-rootProject-publishing = { id = "org.incendo.cloud-build-logic.publishing.root-project", version.ref = "cloud-build-logic" } +cloud-buildLogic-rootProject-spotless = { id = "org.incendo.cloud-build-logic.spotless.root-project", version.ref = "cloud-build-logic" } + +[versions] +# plugins +cloud-build-logic = "0.0.15" +spring-boot = "3.3.4" +kotlin = "2.0.21" +ktlint = "1.3.1" +checkstyle = "10.18.2" + +# libraries +slf4j = "2.0.16" +jspecify = "1.0.0" +apiguardian = "1.1.2" + +# test +truth = "1.4.4" +junit = "5.7.1" + +[libraries] +# plugins +cloud-build-logic = { module = "org.incendo:cloud-build-logic", version.ref = "cloud-build-logic" } +gradleKotlinJvm = { group = "org.jetbrains.kotlin.jvm", name = "org.jetbrains.kotlin.jvm.gradle.plugin", version.ref = "kotlin" } + +# spring +spring-boot-autoconfigure = { group = "org.springframework.boot", name = "spring-boot-autoconfigure" } +spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" } + +# common +slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +jspecify = { group = "org.jspecify", name = "jspecify", version.ref = "jspecify" } +apiguardian = { group = "org.apiguardian", name = "apiguardian-api", version.ref = "apiguardian" } + +# test +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" } +junit-platform = { group = "org.junit.platform", name = "junit-platform-launcher" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..68ae74e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,33 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven("https://oss.sonatype.org/content/repositories/snapshots/") { + name = "sonatypeOssSnapshots" + mavenContent { snapshotsOnly() } + } + } + includeBuild("gradle/build-logic") +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven("https://oss.sonatype.org/content/repositories/snapshots/") { + name = "sonatypeOssSnapshots" + mavenContent { snapshotsOnly() } + } + } +} + +rootProject.name = "disruptor" + +include(":core") +include(":spring") \ No newline at end of file