├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── gradle-build.yml │ └── mega-linter.yml ├── .gitignore ├── .mega-linter.yml ├── LICENSE ├── LICENSE-header.txt ├── README.md ├── build.gradle.kts ├── commons ├── build.gradle.kts └── src │ ├── main │ ├── java │ │ └── one │ │ │ └── tomorrow │ │ │ └── transactionaloutbox │ │ │ └── commons │ │ │ ├── KafkaHeaders.java │ │ │ ├── KafkaProtobufDeserializer.java │ │ │ ├── KafkaProtobufSerializer.java │ │ │ ├── Longs.java │ │ │ ├── Maps.java │ │ │ └── spring │ │ │ ├── ConditionalOnClass.java │ │ │ └── OnClassCondition.java │ └── proto │ │ └── one │ │ └── tomorrow │ │ └── kafka │ │ └── deserializer_messages.proto │ ├── test │ └── java │ │ └── one │ │ └── tomorrow │ │ └── transactionaloutbox │ │ └── commons │ │ ├── KafkaHeadersTest.java │ │ └── LongsTest.java │ └── testFixtures │ └── java │ └── one │ └── tomorrow │ └── transactionaloutbox │ └── commons │ ├── CommonKafkaTestSupport.java │ ├── ProxiedContainerPorts.java │ ├── ProxiedContainerSupport.java │ ├── ProxiedKafkaContainer.java │ └── ProxiedPostgreSQLContainer.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── outbox-kafka-spring-reactive ├── build.gradle.kts ├── lombok.config └── src │ ├── main │ └── java │ │ └── one │ │ └── tomorrow │ │ └── transactionaloutbox │ │ └── reactive │ │ ├── model │ │ ├── OutboxLock.java │ │ └── OutboxRecord.java │ │ ├── repository │ │ ├── OutboxLockRepository.java │ │ └── OutboxRepository.java │ │ ├── service │ │ ├── DefaultKafkaProducerFactory.java │ │ ├── OutboxLockService.java │ │ ├── OutboxProcessor.java │ │ ├── OutboxService.java │ │ └── ProtobufOutboxService.java │ │ └── tracing │ │ ├── MicrometerTracingService.java │ │ ├── NoopTracingService.java │ │ └── TracingService.java │ └── test │ ├── java │ └── one │ │ └── tomorrow │ │ └── transactionaloutbox │ │ └── reactive │ │ ├── AbstractIntegrationTest.java │ │ ├── IntegrationTestConfig.java │ │ ├── KafkaTestSupport.java │ │ ├── TestUtils.java │ │ ├── repository │ │ ├── OutboxLockRepositoryIntegrationTest.java │ │ └── OutboxRepositoryIntegrationTest.java │ │ ├── service │ │ ├── ConcurrentOutboxProcessorsIntegrationTest.java │ │ ├── DefaultKafkaProducerFactoryTest.java │ │ ├── OutboxLockServiceIntegrationTest.java │ │ ├── OutboxProcessorIntegrationTest.java │ │ ├── OutboxServiceIntegrationTest.java │ │ └── ProtobufOutboxServiceIntegrationTest.java │ │ └── tracing │ │ ├── MicrometerTracingIntegrationTestConfig.java │ │ ├── MicrometerTracingServiceTest.java │ │ ├── SimplePropagator.java │ │ └── TracingAssertions.java │ ├── proto │ └── sample.proto │ └── resources │ ├── db │ └── migration │ │ └── V2020.06.19.22.29.00__add-outbox-tables.sql │ └── log4j2-test.xml ├── outbox-kafka-spring ├── build.gradle.kts ├── lombok.config └── src │ ├── main │ └── java │ │ └── one │ │ └── tomorrow │ │ └── transactionaloutbox │ │ ├── model │ │ ├── OutboxLock.java │ │ └── OutboxRecord.java │ │ ├── repository │ │ ├── OutboxLockRepository.java │ │ └── OutboxRepository.java │ │ ├── service │ │ ├── DefaultKafkaProducerFactory.java │ │ ├── OutboxLockService.java │ │ ├── OutboxProcessor.java │ │ ├── OutboxService.java │ │ └── ProtobufOutboxService.java │ │ └── tracing │ │ ├── MicrometerTracingService.java │ │ ├── NoopTracingService.java │ │ └── TracingService.java │ └── test │ ├── java │ └── one │ │ └── tomorrow │ │ └── transactionaloutbox │ │ ├── IntegrationTestConfig.java │ │ ├── KafkaTestSupport.java │ │ ├── TestUtils.java │ │ ├── repository │ │ ├── OutboxLockRepositoryIntegrationTest.java │ │ └── OutboxRepositoryIntegrationTest.java │ │ ├── service │ │ ├── ConcurrentOutboxProcessorsIntegrationTest.java │ │ ├── DefaultKafkaProducerFactoryTest.java │ │ ├── OutboxLockServiceIntegrationTest.java │ │ ├── OutboxProcessorIntegrationTest.java │ │ ├── OutboxProcessorTest.java │ │ ├── OutboxUsageIntegrationTest.java │ │ ├── ProtobufOutboxUsageIntegrationTest.java │ │ ├── SampleProtobufService.java │ │ ├── SampleService.java │ │ └── TransactionalOutboxRepository.java │ │ └── tracing │ │ ├── MicrometerTracingIntegrationTestConfig.java │ │ ├── MicrometerTracingServiceTest.java │ │ ├── SimplePropagator.java │ │ └── TracingAssertions.java │ ├── proto │ └── sample.proto │ └── resources │ ├── db │ └── migration │ │ └── V2020.06.19.22.29.00__add-outbox-tables.sql │ ├── hibernate-types.properties │ └── log4j2-test.xml ├── settings.gradle.kts └── update_copyright_headers.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | curly_bracket_next_line = false 11 | spaces_around_operators = true 12 | 13 | [*.{java,kt,kts}] 14 | indent_size = 4 15 | continuation_indent_size = 8 16 | 17 | [*.{js,ts}] 18 | quote_type = single 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [*.bat] 24 | indent_style = unset 25 | indent_size = unset 26 | end_of_line = unset 27 | charset = unset 28 | trim_trailing_whitespace = unset 29 | insert_final_newline = unset 30 | 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/gradle-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 6 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle 7 | 8 | name: ci 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: write 18 | # Note that this permission will not be available if the PR is from a forked repository 19 | 20 | jobs: 21 | build: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up java 28 | uses: actions/setup-java@v4 29 | with: 30 | java-version: '17' 31 | distribution: 'temurin' 32 | cache: 'gradle' 33 | - name: Validate gradle wrapper 34 | uses: gradle/wrapper-validation-action@v2 35 | - name: Setup Gradle to generate and submit dependency graphs 36 | uses: gradle/gradle-build-action@v3 37 | with: 38 | dependency-graph: generate-and-submit 39 | - name: Build 40 | uses: gradle/gradle-build-action@v3 41 | with: 42 | arguments: build 43 | 44 | dependency-review: 45 | needs: build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Perform dependency review 49 | uses: actions/dependency-review-action@v4 50 | with: 51 | fail-on-severity: high 52 | allow-ghsas: GHSA-h3qr-39j9-4r5v 53 | base-ref: ${{ github.event.pull_request.base.sha || 'main' }} 54 | head-ref: ${{ github.event.pull_request.head.sha || github.ref }} 55 | 56 | publish: 57 | needs: [ build ] 58 | if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Set up java 63 | uses: actions/setup-java@v4 64 | with: 65 | java-version: '17' 66 | distribution: 'temurin' 67 | cache: 'gradle' 68 | - name: Publish 69 | uses: gradle/gradle-build-action@v3 70 | env: 71 | # variables used by build.gradle.kts for signing / publishing (without 'ORG_GRADLE_PROJECT_' prefix) 72 | ORG_GRADLE_PROJECT_signingKeyId: ${{ secrets.OSSRH_GPG_SECRET_KEY_ID }} 73 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }} 74 | ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.OSSRH_GPG_SECRET_PASSWORD }} 75 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} 76 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} 77 | with: 78 | arguments: publish 79 | -------------------------------------------------------------------------------- /.github/workflows/mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # MegaLinter GitHub Action configuration file 3 | # More info at https://megalinter.io 4 | name: MegaLinter 5 | 6 | on: 7 | # Trigger mega-linter at every push. Action will also be visible from Pull Requests to main 8 | # push: # Comment this line to trigger action only on pull-requests (not recommended if you don't pay for GH Actions) 9 | pull_request: 10 | branches: [main] 11 | 12 | env: # Comment env block if you do not want to apply fixes 13 | # Apply linter fixes configuration 14 | APPLY_FIXES: all # When active, APPLY_FIXES must also be defined as environment variable (in github/workflows/mega-linter.yml or other CI tool) 15 | APPLY_FIXES_EVENT: pull_request # Decide which event triggers application of fixes in a commit or a PR (pull_request, push, all) 16 | APPLY_FIXES_MODE: pull_request # If APPLY_FIXES is used, defines if the fixes are directly committed (commit) or posted in a PR (pull_request) 17 | 18 | concurrency: 19 | group: ${{ github.ref }}-${{ github.workflow }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | build: 24 | name: MegaLinter 25 | runs-on: ubuntu-latest 26 | permissions: 27 | # Give the default GITHUB_TOKEN write permission to commit and push, comment issues & post new PR 28 | # Remove the ones you do not need 29 | #contents: write 30 | issues: write 31 | pull-requests: write 32 | steps: 33 | # Git Checkout 34 | - name: Checkout Code 35 | uses: actions/checkout@v4 36 | with: 37 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 38 | fetch-depth: 0 # If you use VALIDATE_ALL_CODEBASE = true, you can remove this line to improve performances 39 | 40 | # MegaLinter 41 | - name: MegaLinter 42 | id: ml 43 | # You can override MegaLinter flavor used to have faster performances 44 | # More info at https://megalinter.io/flavors/ 45 | uses: oxsecurity/megalinter@v7 46 | env: 47 | # All available variables are described in documentation 48 | # https://megalinter.io/configuration/ 49 | VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} # Validates all source when push on main, else just the git diff with main. Override with true if you always want to lint all sources 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | # ADD YOUR CUSTOM ENV VARIABLES HERE OR DEFINE THEM IN A FILE .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY 52 | # DISABLE: COPYPASTE,SPELL # Uncomment to disable copy-paste and spell checks 53 | 54 | # Upload MegaLinter artifacts 55 | - name: Archive production artifacts 56 | if: ${{ success() }} || ${{ failure() }} 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: MegaLinter reports 60 | path: | 61 | megalinter-reports 62 | mega-linter.log 63 | 64 | # Create pull request if applicable (for now works only on PR from same repository, not from forks) 65 | - name: Create Pull Request with applied fixes 66 | id: cpr 67 | if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'pull_request' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) 68 | uses: peter-evans/create-pull-request@v6 69 | with: 70 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 71 | commit-message: "[MegaLinter] Apply linters automatic fixes" 72 | title: "[MegaLinter] Apply linters automatic fixes" 73 | labels: bot 74 | - name: Create PR output 75 | if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'pull_request' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) 76 | run: | 77 | echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" 78 | echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" 79 | 80 | # Push new commit if applicable (for now works only on PR from same repository, not from forks) 81 | - name: Prepare commit 82 | if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'commit' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) 83 | run: sudo chown -Rc $UID .git/ 84 | - name: Commit and push applied linter fixes 85 | if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'commit' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) 86 | uses: stefanzweifel/git-auto-commit-action@v5 87 | with: 88 | branch: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref }} 89 | commit_message: "[MegaLinter] Apply linters fixes" 90 | commit_user_name: megalinter-bot 91 | commit_user_email: nicolas.vuillamy@ox.security 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | ENABLE: 2 | - EDITORCONFIG 3 | -------------------------------------------------------------------------------- /LICENSE-header.txt: -------------------------------------------------------------------------------- 1 | Copyright ${year} Tomorrow GmbH @ https://tomorrow.one 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /commons/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | // the version is set in parent/root build.gradle.kts 3 | 4 | dependencies { 5 | val springVersion = "6.2.6" 6 | val kafkaVersion = "3.9.0" 7 | val springKafkaVersion = "3.3.5" 8 | val sl4jVersion = "2.0.17" 9 | val junitVersion = "5.12.2" 10 | val testcontainersVersion = "1.21.0" 11 | 12 | "protobufSupportImplementation"("com.google.protobuf:protobuf-java:${rootProject.extra["protobufVersion"]}") 13 | implementation("org.apache.kafka:kafka-clients:$kafkaVersion") 14 | implementation("org.springframework:spring-core:$springVersion") 15 | implementation("org.springframework:spring-context:$springVersion") 16 | implementation("org.slf4j:slf4j-api:$sl4jVersion") 17 | 18 | testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") 19 | testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") 20 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion") 21 | testRuntimeOnly("org.slf4j:slf4j-simple:$sl4jVersion") 22 | 23 | testFixturesImplementation("org.springframework:spring-test:$springVersion") 24 | testFixturesImplementation("org.apache.kafka:kafka-clients:$kafkaVersion") 25 | testFixturesImplementation("org.springframework.kafka:spring-kafka:$springKafkaVersion") 26 | testFixturesImplementation("org.springframework.kafka:spring-kafka-test:$springKafkaVersion") 27 | testFixturesImplementation("org.slf4j:slf4j-api:$sl4jVersion") 28 | testFixturesImplementation("com.google.protobuf:protobuf-java:${rootProject.extra["protobufVersion"]}") 29 | testFixturesApi("org.junit.jupiter:junit-jupiter-api:$junitVersion") 30 | testFixturesApi("org.testcontainers:postgresql:$testcontainersVersion") 31 | testFixturesApi("org.testcontainers:kafka:$testcontainersVersion") 32 | testFixturesApi("org.testcontainers:toxiproxy:$testcontainersVersion") 33 | 34 | } 35 | -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/KafkaHeaders.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import org.apache.kafka.common.header.Header; 19 | import org.apache.kafka.common.header.Headers; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import java.math.BigInteger; 24 | import java.util.Arrays; 25 | import java.util.HashMap; 26 | import java.util.Map; 27 | import java.util.function.Function; 28 | 29 | @SuppressWarnings("unused") 30 | public class KafkaHeaders { 31 | 32 | private static final Logger logger = LoggerFactory.getLogger(KafkaHeaders.class); 33 | 34 | /** 35 | * The name for the header to store service that published the event. 36 | * Useful in a migration scenario (an event is going to be published by a different service). 37 | */ 38 | public static final String HEADERS_SOURCE_NAME = "x-source"; 39 | /** 40 | * The name for the header to store the sequence as long. Because header values are stored as byte[], 41 | * the value is expected to be the big-endian representation of the long in an 8-element byte array.
42 | * To transform a long to byte[], you can use {@link Longs#toByteArray(long)}.
43 | * Note: {@link BigInteger#toByteArray()} does not return the appropriate representation! 44 | */ 45 | public static final String HEADERS_SEQUENCE_NAME = "x-sequence"; 46 | /** The header to store the type of the value, so data can be deserialized to that type. */ 47 | public static final String HEADERS_VALUE_TYPE_NAME = "x-value-type"; 48 | public static final String HEADERS_DLT_SOURCE_NAME = "x-deadletter-source"; 49 | public static final String HEADERS_DLT_RETRY_NAME = "x-deadletter-retry"; 50 | 51 | public static Map knownHeaders(Headers headers) { 52 | Map res = new HashMap<>(); 53 | addHeaderIfPresent(HEADERS_VALUE_TYPE_NAME, String::new, headers, res); 54 | addHeaderIfPresent(HEADERS_SOURCE_NAME, String::new, headers, res); 55 | addHeaderIfPresent(HEADERS_SEQUENCE_NAME, Longs::toLong, headers, res); 56 | addHeaderIfPresent(HEADERS_DLT_SOURCE_NAME, String::new, headers, res); 57 | addHeaderIfPresent(HEADERS_DLT_RETRY_NAME, Longs::toLong, headers, res); 58 | return res; 59 | } 60 | 61 | private static void addHeaderIfPresent(String key, Function valueTransformer, Headers headers, Map res) { 62 | Header header = headers.lastHeader(key); 63 | if (header != null) { 64 | try { 65 | res.put(key, valueTransformer.apply(header.value())); 66 | } catch (Exception e) { 67 | logger.warn("Failed to transform byte array {} for key {}: {}", Arrays.toString(header.value()), key, e.getMessage(), e); 68 | } 69 | } 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/KafkaProtobufDeserializer.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import com.google.protobuf.ByteString; 19 | import com.google.protobuf.Descriptors.Descriptor; 20 | import com.google.protobuf.Message; 21 | import one.tomorrow.kafka.messages.DeserializerMessages.InvalidMessage; 22 | import one.tomorrow.kafka.messages.DeserializerMessages.UnsupportedMessage; 23 | import org.apache.kafka.common.header.Headers; 24 | import org.apache.kafka.common.serialization.Deserializer; 25 | import org.springframework.util.ReflectionUtils; 26 | 27 | import java.lang.reflect.InvocationTargetException; 28 | import java.lang.reflect.Method; 29 | import java.util.AbstractMap.SimpleEntry; 30 | import java.util.Map; 31 | import java.util.Map.Entry; 32 | import java.util.Optional; 33 | 34 | import static java.util.stream.Collectors.toMap; 35 | import static java.util.stream.StreamSupport.stream; 36 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_VALUE_TYPE_NAME; 37 | 38 | public class KafkaProtobufDeserializer implements Deserializer { 39 | 40 | private final Map> valueTypes; 41 | private final boolean throwOnError; 42 | 43 | public KafkaProtobufDeserializer(Iterable> valueTypes, boolean throwOnError) { 44 | this.valueTypes = stream(valueTypes.spliterator(), false) 45 | .map(this::protoFullNameToClass) 46 | .collect(toMap(Entry::getKey, Entry::getValue)); 47 | this.throwOnError = throwOnError; 48 | } 49 | 50 | public KafkaProtobufDeserializer(Map> valueTypes, boolean throwOnError) { 51 | this.valueTypes = valueTypes; 52 | this.throwOnError = throwOnError; 53 | } 54 | 55 | @Override 56 | public void configure(Map configs, boolean isKey) { } 57 | 58 | @Override 59 | public Message deserialize(String topic, byte[] data) { 60 | return getInvalidMessageOrThrow("Headers not available", data); 61 | } 62 | 63 | @Override 64 | public Message deserialize(String topic, Headers headers, byte[] data) { 65 | String headerValueType = Optional 66 | .ofNullable(headers.lastHeader(HEADERS_VALUE_TYPE_NAME)) 67 | .map(h -> new String(h.value())) 68 | .orElse(null); 69 | 70 | if (headerValueType == null) { 71 | return getInvalidMessageOrThrow("No '" + HEADERS_VALUE_TYPE_NAME + "' header present", data); 72 | } 73 | 74 | Class clazz = valueTypes.get(headerValueType); 75 | if (clazz == null) { 76 | if (throwOnError) 77 | throw new IllegalArgumentException("Unknown type " + headerValueType); 78 | 79 | return UnsupportedMessage.newBuilder() 80 | .setData(ByteString.copyFrom(data)) 81 | .build(); 82 | } 83 | 84 | return getMessage(headers, data, clazz); 85 | } 86 | 87 | private Message getMessage(Headers headers, byte[] data, Class clazz) { 88 | try { 89 | Method m; 90 | if (Message.class.isAssignableFrom(clazz)) { 91 | m = ReflectionUtils.findMethod(clazz, "parseFrom", byte[].class); 92 | } else { 93 | return getInvalidMessageOrThrow("Not a supported class: " + clazz, data); 94 | } 95 | if (null == m) { 96 | return getInvalidMessageOrThrow("Class " + clazz + " does not provide 'parseFrom(byte[])'", data); 97 | } 98 | 99 | return (Message) m.invoke(null, (Object) data); 100 | } catch (IllegalArgumentException e) { 101 | throw e; 102 | } catch (Exception e) { 103 | return getInvalidMessageOrThrow("Failed to parse data with class " + clazz, data, e); 104 | } 105 | } 106 | 107 | private InvalidMessage getInvalidMessageOrThrow(String error, byte[] data) { 108 | return getInvalidMessageOrThrow(error, data, null); 109 | } 110 | 111 | private InvalidMessage getInvalidMessageOrThrow(String error, byte[] data, Exception cause) { 112 | if (throwOnError) 113 | throw new IllegalArgumentException(error, cause); 114 | 115 | return InvalidMessage.newBuilder() 116 | .setError(error) 117 | .setData(ByteString.copyFrom(data)) 118 | .build(); 119 | } 120 | 121 | @Override 122 | public void close() { 123 | } 124 | 125 | private SimpleEntry> protoFullNameToClass(Class valueTypeClass) { 126 | try { 127 | Method getDescriptor = valueTypeClass.getMethod("getDescriptor"); 128 | String protoFullName = ((Descriptor) getDescriptor.invoke(null)).getFullName(); 129 | return new SimpleEntry<>(protoFullName, valueTypeClass); 130 | } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { 131 | throw new RuntimeException(e); 132 | } 133 | } 134 | 135 | } -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/KafkaProtobufSerializer.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import com.google.protobuf.MessageLite; 19 | import org.apache.kafka.common.serialization.Serializer; 20 | 21 | import java.util.Map; 22 | 23 | /** 24 | * Serializer for Kafka to serialize Protocol Buffers messages 25 | * 26 | * @param Protobuf message type 27 | */ 28 | public class KafkaProtobufSerializer implements Serializer { 29 | 30 | @Override 31 | public void configure(Map configs, boolean isKey) { 32 | } 33 | 34 | @Override 35 | public byte[] serialize(String topic, T data) { 36 | return data.toByteArray(); 37 | } 38 | 39 | @Override 40 | public void close() { 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/Longs.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | public class Longs { 19 | 20 | /** 21 | * Returns a big-endian representation of value in an 8-element byte array; equivalent to 22 | * ByteBuffer.allocate(8).putLong(value).array().
23 | * For example, the input value 0x1213141516171819L would yield the byte array 24 | * {0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19}. 25 | */ 26 | public static byte[] toByteArray(long data) { 27 | return new byte[] { 28 | (byte) (data >>> 56), 29 | (byte) (data >>> 48), 30 | (byte) (data >>> 40), 31 | (byte) (data >>> 32), 32 | (byte) (data >>> 24), 33 | (byte) (data >>> 16), 34 | (byte) (data >>> 8), 35 | (byte) data 36 | }; 37 | } 38 | 39 | /** 40 | * Returns the long value whose big-endian representation is stored in the given byte array (length must be 8); 41 | * equivalent to ByteBuffer.wrap(bytes).getLong().
42 | * For example, the input byte array {0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19} would yield the 43 | * long value 0x1213141516171819L. 44 | * @param data array of 8 bytes, the big-endian representation of the long 45 | * @return the long value 46 | * @throws IllegalArgumentException if data is null or has length != 8. 47 | */ 48 | public static long toLong(byte[] data) { 49 | if (data == null || data.length != 8) { 50 | throw new IllegalArgumentException("Size of data received is not 8"); 51 | } 52 | 53 | long value = 0; 54 | for (byte b : data) { 55 | value <<= 8; 56 | value |= b & 0xFF; 57 | } 58 | return value; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/Maps.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import java.util.HashMap; 19 | import java.util.Map; 20 | 21 | public class Maps { 22 | 23 | public static Map merge(Map map1, Map map2) { 24 | if (isNullOrEmpty(map1)) 25 | return map2; 26 | if (isNullOrEmpty(map2)) 27 | return map1; 28 | Map result = new HashMap<>(map1); 29 | result.putAll(map2); 30 | return result; 31 | } 32 | 33 | public static boolean isNullOrEmpty(Map map) { 34 | return map == null || map.isEmpty(); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/spring/ConditionalOnClass.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons.spring; 17 | 18 | import org.springframework.context.annotation.Conditional; 19 | 20 | import java.lang.annotation.*; 21 | 22 | @Target(ElementType.TYPE) 23 | @Retention(RetentionPolicy.RUNTIME) 24 | @Documented 25 | @Conditional(OnClassCondition.class) 26 | public @interface ConditionalOnClass { 27 | 28 | /** The class that must be present. */ 29 | Class value(); 30 | 31 | } 32 | -------------------------------------------------------------------------------- /commons/src/main/java/one/tomorrow/transactionaloutbox/commons/spring/OnClassCondition.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons.spring; 17 | 18 | import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; 19 | import org.springframework.context.annotation.Condition; 20 | import org.springframework.context.annotation.ConditionContext; 21 | import org.springframework.core.type.AnnotatedTypeMetadata; 22 | 23 | public class OnClassCondition implements Condition { 24 | 25 | @Override 26 | public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { 27 | String className = metadata.getAnnotations().get(ConditionalOnClass.class).getString("value"); 28 | try { 29 | ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); 30 | ClassLoader beanClassLoader; 31 | if (beanFactory != null && (beanClassLoader = beanFactory.getBeanClassLoader()) != null) { 32 | beanClassLoader.loadClass(className); 33 | return true; 34 | } 35 | } catch (ClassNotFoundException e) { 36 | // ignore 37 | } 38 | return false; 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /commons/src/main/proto/one/tomorrow/kafka/deserializer_messages.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package one.tomorrow.kafka; 4 | 5 | option java_package = "one.tomorrow.kafka.messages"; 6 | 7 | // For messages which are missing required headers, or where parsing the data failed 8 | message InvalidMessage { 9 | string error = 1; 10 | bytes data = 2; 11 | } 12 | 13 | // For messages of an unsupported type (according to the "x-value-type" header) 14 | message UnsupportedMessage { 15 | bytes data = 1; 16 | } -------------------------------------------------------------------------------- /commons/src/test/java/one/tomorrow/transactionaloutbox/commons/KafkaHeadersTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import org.apache.kafka.common.header.internals.RecordHeaders; 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.util.HashMap; 22 | import java.util.Map; 23 | 24 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.*; 25 | import static one.tomorrow.transactionaloutbox.commons.Longs.toByteArray; 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | 28 | class KafkaHeadersTest { 29 | 30 | @Test 31 | void knownHeadersShouldReturnGivenHeaders() { 32 | RecordHeaders headers = recordHeaders(HEADERS_SEQUENCE_NAME, toByteArray(42)); 33 | assertEquals(mapOf(HEADERS_SEQUENCE_NAME, 42L), knownHeaders(headers)); 34 | 35 | headers = recordHeaders(HEADERS_SOURCE_NAME, "foo".getBytes()); 36 | assertEquals(mapOf(HEADERS_SOURCE_NAME, "foo"), knownHeaders(headers)); 37 | 38 | headers = recordHeaders(HEADERS_VALUE_TYPE_NAME, "foo".getBytes()); 39 | assertEquals(mapOf(HEADERS_VALUE_TYPE_NAME, "foo"), knownHeaders(headers)); 40 | 41 | headers = recordHeaders(HEADERS_DLT_SOURCE_NAME, "foo".getBytes()); 42 | assertEquals(mapOf(HEADERS_DLT_SOURCE_NAME, "foo"), knownHeaders(headers)); 43 | 44 | headers = recordHeaders(HEADERS_DLT_RETRY_NAME, toByteArray(42)); 45 | assertEquals(mapOf(HEADERS_DLT_RETRY_NAME, 42L), knownHeaders(headers)); 46 | 47 | headers = new RecordHeaders(); 48 | headers.add(HEADERS_SEQUENCE_NAME, toByteArray(42)); 49 | headers.add(HEADERS_SOURCE_NAME, "foo".getBytes()); 50 | headers.add(HEADERS_VALUE_TYPE_NAME, "bar".getBytes()); 51 | headers.add(HEADERS_DLT_SOURCE_NAME, "baz".getBytes()); 52 | headers.add(HEADERS_DLT_RETRY_NAME, toByteArray(16)); 53 | headers.add("some-header", "baz".getBytes()); 54 | assertEquals( 55 | mapOf( 56 | HEADERS_SEQUENCE_NAME, 42L, 57 | HEADERS_SOURCE_NAME, "foo", 58 | HEADERS_VALUE_TYPE_NAME, "bar", 59 | HEADERS_DLT_SOURCE_NAME, "baz", 60 | HEADERS_DLT_RETRY_NAME, 16L 61 | ), 62 | knownHeaders(headers) 63 | ); 64 | } 65 | 66 | @Test 67 | void knownHeadersShouldGracefullyHandleBadSequenceRepresentation() { 68 | RecordHeaders headers = new RecordHeaders(); 69 | headers.add(HEADERS_SEQUENCE_NAME, new byte[0]); 70 | headers.add(HEADERS_SOURCE_NAME, "foo".getBytes()); 71 | assertEquals(mapOf(HEADERS_SOURCE_NAME, "foo"), knownHeaders(headers)); 72 | } 73 | 74 | private RecordHeaders recordHeaders(String key, byte[] value) { 75 | RecordHeaders headers = new RecordHeaders(); 76 | headers.add(key, value); 77 | return headers; 78 | } 79 | 80 | private Map mapOf(String key, Object value) { 81 | Map result = new HashMap<>(); 82 | result.put(key, value); 83 | return result; 84 | } 85 | 86 | private Map mapOf(String key1, Object value1, 87 | String key2, Object value2, 88 | String key3, Object value3, 89 | String key4, Object value4, 90 | String key5, Object value5) { 91 | Map result = new HashMap<>(); 92 | result.put(key1, value1); 93 | result.put(key2, value2); 94 | result.put(key3, value3); 95 | result.put(key4, value4); 96 | result.put(key5, value5); 97 | return result; 98 | } 99 | 100 | } -------------------------------------------------------------------------------- /commons/src/test/java/one/tomorrow/transactionaloutbox/commons/LongsTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import org.junit.jupiter.params.ParameterizedTest; 19 | import org.junit.jupiter.params.provider.MethodSource; 20 | 21 | import java.nio.ByteBuffer; 22 | import java.util.Random; 23 | import java.util.stream.LongStream; 24 | 25 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | 28 | class LongsTest { 29 | 30 | @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") 31 | @MethodSource("longValues") 32 | void toLong(long value) { 33 | byte[] bytes = ByteBuffer.allocate(8).putLong(value).array(); 34 | long converted = Longs.toLong(bytes); 35 | assertEquals(value, converted); 36 | } 37 | 38 | @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") 39 | @MethodSource("longValues") 40 | void toByteArray(long value) { 41 | byte[] bytes = Longs.toByteArray(value); 42 | byte[] expected = ByteBuffer.allocate(8).putLong(value).array(); 43 | assertArrayEquals(expected, bytes); 44 | } 45 | 46 | @SuppressWarnings("unused") 47 | static LongStream longValues() { 48 | Random r = new Random(); 49 | return LongStream.concat( 50 | LongStream.of(-1, 0, 1, 2, Long.MAX_VALUE), 51 | LongStream.generate(() -> Math.abs(r.nextLong())) 52 | ).limit(10); 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /commons/src/testFixtures/java/one/tomorrow/transactionaloutbox/commons/ProxiedContainerPorts.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | 21 | import java.io.IOException; 22 | import java.net.ServerSocket; 23 | import java.util.HashMap; 24 | import java.util.Map; 25 | import java.util.concurrent.atomic.AtomicInteger; 26 | 27 | public class ProxiedContainerPorts { 28 | 29 | private static final Logger LOGGER = LoggerFactory.getLogger(ProxiedContainerPorts.class); 30 | 31 | /** First and last proxied ports from {@link org.testcontainers.containers.ToxiproxyContainer} */ 32 | private static final int FIRST_PROXIED_PORT = 8666; 33 | private static final int LAST_PROXIED_PORT = 8666 + 31; 34 | 35 | private static final AtomicInteger NEXT_PORT = new AtomicInteger(FIRST_PROXIED_PORT); 36 | private static final Map PORT_BY_SERVICE = new HashMap<>(); 37 | 38 | 39 | public static int findPort(String service) { 40 | Integer result = PORT_BY_SERVICE.get(service); 41 | if (result != null) 42 | return result; 43 | 44 | result = findNextPort(NEXT_PORT.getAndIncrement()); 45 | LOGGER.info("Setting port for {} to {}", service, result); 46 | PORT_BY_SERVICE.put(service, result); 47 | return result; 48 | } 49 | 50 | private static int findNextPort(int port) { 51 | if (port > LAST_PROXIED_PORT) { 52 | throw new RuntimeException("Could not find free port for proxied container"); 53 | } 54 | try (ServerSocket socket = new ServerSocket(port)) { 55 | return socket.getLocalPort(); 56 | } catch (IOException e) { 57 | LOGGER.info("Port {} is already in use, trying next port", port); 58 | return findNextPort(NEXT_PORT.getAndIncrement()); 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /commons/src/testFixtures/java/one/tomorrow/transactionaloutbox/commons/ProxiedContainerSupport.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import eu.rekawek.toxiproxy.Proxy; 19 | import eu.rekawek.toxiproxy.ToxiproxyClient; 20 | import eu.rekawek.toxiproxy.model.ToxicDirection; 21 | import org.testcontainers.containers.ToxiproxyContainer; 22 | 23 | import java.io.IOException; 24 | import java.util.concurrent.atomic.AtomicBoolean; 25 | 26 | import static one.tomorrow.transactionaloutbox.commons.ProxiedContainerPorts.findPort; 27 | 28 | public interface ProxiedContainerSupport { 29 | 30 | String CUT_CONNECTION_DOWNSTREAM = "CUT_CONNECTION_DOWNSTREAM"; 31 | String CUT_CONNECTION_UPSTREAM = "CUT_CONNECTION_UPSTREAM"; 32 | 33 | AtomicBoolean isCurrentlyCut = new AtomicBoolean(false); 34 | 35 | Proxy getProxy(); 36 | 37 | /** 38 | * Cuts the connection by setting bandwidth in both directions to zero. 39 | * @param shouldCutConnection true if the connection should be cut, or false if it should be re-enabled 40 | */ 41 | default void setConnectionCut(boolean shouldCutConnection) { 42 | synchronized (isCurrentlyCut) { 43 | if (shouldCutConnection != isCurrentlyCut.get()) { 44 | try { 45 | if (shouldCutConnection) { 46 | getProxy().toxics().bandwidth(CUT_CONNECTION_DOWNSTREAM, ToxicDirection.DOWNSTREAM, 0); 47 | getProxy().toxics().bandwidth(CUT_CONNECTION_UPSTREAM, ToxicDirection.UPSTREAM, 0); 48 | isCurrentlyCut.set(true); 49 | } else { 50 | getProxy().toxics().get(CUT_CONNECTION_DOWNSTREAM).remove(); 51 | getProxy().toxics().get(CUT_CONNECTION_UPSTREAM).remove(); 52 | isCurrentlyCut.set(false); 53 | } 54 | } catch (IOException e) { 55 | throw new RuntimeException("Could not control proxy", e); 56 | } 57 | } 58 | } 59 | } 60 | 61 | static Proxy createProxy(String service, ToxiproxyContainer toxiproxy, int exposedPort) { 62 | final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); 63 | Proxy proxy; 64 | try { 65 | proxy = toxiproxyClient.createProxy( 66 | service, 67 | "0.0.0.0:" + findPort(service), 68 | service + ":" + exposedPort 69 | ); 70 | } catch (IOException e) { 71 | throw new RuntimeException(e); 72 | } 73 | return proxy; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /commons/src/testFixtures/java/one/tomorrow/transactionaloutbox/commons/ProxiedKafkaContainer.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import eu.rekawek.toxiproxy.Proxy; 19 | import org.testcontainers.containers.KafkaContainer; 20 | import org.testcontainers.containers.Network; 21 | import org.testcontainers.containers.ToxiproxyContainer; 22 | import org.testcontainers.utility.DockerImageName; 23 | 24 | import static one.tomorrow.transactionaloutbox.commons.ProxiedContainerPorts.findPort; 25 | import static one.tomorrow.transactionaloutbox.commons.ProxiedContainerSupport.createProxy; 26 | 27 | public class ProxiedKafkaContainer extends KafkaContainer implements ProxiedContainerSupport { 28 | 29 | private static ProxiedKafkaContainer kafka; 30 | private static ToxiproxyContainer toxiproxy; 31 | public static Proxy kafkaProxy; 32 | public static String bootstrapServers; 33 | 34 | public ProxiedKafkaContainer(DockerImageName dockerImageName) { 35 | super(dockerImageName); 36 | } 37 | 38 | public static ProxiedKafkaContainer startProxiedKafka() { 39 | if (kafka == null) { 40 | int exposedKafkaPort = KAFKA_PORT; 41 | 42 | Network network = Network.newNetwork(); 43 | 44 | kafka = (ProxiedKafkaContainer) new ProxiedKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.1.2")) 45 | .withExposedPorts(exposedKafkaPort) 46 | .withNetwork(network) 47 | .withNetworkAliases("kafka"); 48 | 49 | toxiproxy = new ToxiproxyContainer(DockerImageName.parse("ghcr.io/shopify/toxiproxy:2.5.0")) 50 | .withNetwork(network); 51 | toxiproxy.start(); 52 | 53 | kafkaProxy = createProxy("kafka", toxiproxy, exposedKafkaPort); 54 | bootstrapServers = "PLAINTEXT://" + toxiproxy.getHost() + ":" + toxiproxy.getMappedPort(findPort("kafka")); 55 | 56 | kafka.start(); 57 | } 58 | return kafka; 59 | } 60 | 61 | public static void stopProxiedKafka() { 62 | if (toxiproxy != null) 63 | toxiproxy.stop(); 64 | if (kafka != null) 65 | kafka.stop(); 66 | } 67 | 68 | /** Kafka advertises its connection to connected clients, therefore we must override this */ 69 | public String getBootstrapServers() { 70 | return bootstrapServers; 71 | } 72 | 73 | @Override 74 | public Proxy getProxy() { 75 | return kafkaProxy; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /commons/src/testFixtures/java/one/tomorrow/transactionaloutbox/commons/ProxiedPostgreSQLContainer.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.commons; 17 | 18 | import eu.rekawek.toxiproxy.Proxy; 19 | import org.jetbrains.annotations.NotNull; 20 | import org.springframework.test.context.DynamicPropertyRegistry; 21 | import org.testcontainers.containers.Network; 22 | import org.testcontainers.containers.PostgreSQLContainer; 23 | import org.testcontainers.containers.ToxiproxyContainer; 24 | import org.testcontainers.utility.DockerImageName; 25 | 26 | import static one.tomorrow.transactionaloutbox.commons.ProxiedContainerPorts.findPort; 27 | import static one.tomorrow.transactionaloutbox.commons.ProxiedContainerSupport.createProxy; 28 | 29 | public class ProxiedPostgreSQLContainer extends PostgreSQLContainer implements ProxiedContainerSupport { 30 | 31 | private static ProxiedPostgreSQLContainer postgres; 32 | private static ToxiproxyContainer toxiproxy; 33 | public static Proxy postgresProxy; 34 | private static String jdbcUrl; 35 | 36 | public ProxiedPostgreSQLContainer(DockerImageName dockerImageName) { 37 | super(dockerImageName); 38 | } 39 | 40 | public static ProxiedPostgreSQLContainer startProxiedPostgres() { 41 | if (postgres == null) { 42 | int exposedPostgresPort = POSTGRESQL_PORT; 43 | 44 | Network network = Network.newNetwork(); 45 | 46 | postgres = new ProxiedPostgreSQLContainer(DockerImageName.parse("postgres:13.7")) 47 | .withExposedPorts(exposedPostgresPort) 48 | .withNetwork(network) 49 | .withNetworkAliases("postgres"); 50 | 51 | toxiproxy = new ToxiproxyContainer(DockerImageName.parse("ghcr.io/shopify/toxiproxy:2.5.0")) 52 | .withNetwork(network); 53 | toxiproxy.start(); 54 | 55 | postgresProxy = createProxy("postgres", toxiproxy, exposedPostgresPort); 56 | 57 | jdbcUrl = "jdbc:postgresql://" + getHostAndPort() + "/" + postgres.getDatabaseName(); 58 | 59 | postgres.start(); 60 | } 61 | return postgres; 62 | } 63 | 64 | @NotNull 65 | private static String getHostAndPort() { 66 | return toxiproxy.getHost() + ":" + toxiproxy.getMappedPort(findPort("postgres")); 67 | } 68 | 69 | public static void stopProxiedPostgres() { 70 | if (toxiproxy != null) 71 | toxiproxy.stop(); 72 | if (postgres != null) 73 | postgres.stop(); 74 | } 75 | 76 | public static void setConnectionProperties(DynamicPropertyRegistry registry) { 77 | if (postgres == null) 78 | startProxiedPostgres(); 79 | 80 | registry.add("spring.r2dbc.url", () -> "r2dbc:postgresql://" + getHostAndPort() + "/" + postgres.getDatabaseName()); 81 | registry.add("spring.r2dbc.username", () -> postgres.getUsername()); 82 | registry.add("spring.r2dbc.password", () -> postgres.getPassword()); 83 | } 84 | 85 | @Override 86 | public String getJdbcUrl() { 87 | return jdbcUrl; 88 | } 89 | 90 | @Override 91 | public Proxy getProxy() { 92 | return postgresProxy; 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomorrow-one/transactional-outbox/a4c87a78919f82da91f0631b6c24ef9cee19e5ca/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | // the version is set in parent/root build.gradle.kts 3 | 4 | tasks.withType { 5 | options.compilerArgs.addAll(listOf("-parameters")) 6 | } 7 | 8 | dependencies { 9 | val springVersion = "6.2.6" 10 | val springDataVersion = "3.4.5" 11 | val kafkaVersion = "3.9.0" 12 | val testcontainersVersion = "1.21.0" 13 | val log4jVersion = "2.24.3" 14 | 15 | implementation("org.springframework:spring-context:$springVersion") 16 | 17 | implementation("org.springframework.data:spring-data-relational:$springDataVersion") 18 | implementation("org.springframework.data:spring-data-r2dbc:$springDataVersion") 19 | implementation("org.springframework:spring-r2dbc:$springVersion") 20 | implementation("org.postgresql:r2dbc-postgresql:1.0.7.RELEASE") 21 | implementation("org.apache.kafka:kafka-clients:$kafkaVersion") 22 | "protobufSupportImplementation"("com.google.protobuf:protobuf-java:${rootProject.extra["protobufVersion"]}") 23 | implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3") 24 | implementation("org.slf4j:slf4j-api:2.0.17") 25 | implementation("javax.annotation:javax.annotation-api:1.3.2") 26 | implementation(project(":commons")) 27 | implementation(platform("io.micrometer:micrometer-tracing-bom:1.4.5")) 28 | compileOnly("io.micrometer:micrometer-tracing") 29 | 30 | // testing 31 | testImplementation(testFixtures(project(":commons"))) 32 | testImplementation("org.springframework.boot:spring-boot-autoconfigure:3.4.4") 33 | testImplementation("io.projectreactor:reactor-test:3.7.5") 34 | 35 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 36 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") 37 | testRuntimeOnly("org.junit.vintage:junit-vintage-engine") 38 | 39 | testImplementation("org.hamcrest:hamcrest:3.0") 40 | testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion") 41 | testImplementation("org.springframework:spring-jdbc:$springVersion") 42 | testImplementation("io.r2dbc:r2dbc-pool:1.0.2.RELEASE") 43 | // update gson version to fix a conflict of toxiproxy dependency and spring GsonAutoConfiguration 44 | testRuntimeOnly("com.google.code.gson:gson:2.13.1") 45 | testImplementation("org.postgresql:postgresql:42.7.5") 46 | testImplementation("org.flywaydb:flyway-database-postgresql:11.8.0") 47 | testImplementation("org.flywaydb.flyway-test-extensions:flyway-spring-test:10.0.0") 48 | testImplementation("org.awaitility:awaitility:4.3.0") 49 | testImplementation("org.apache.logging.log4j:log4j-core:$log4jVersion") 50 | testImplementation("org.apache.logging.log4j:log4j-slf4j2-impl:$log4jVersion") 51 | testImplementation("org.apache.commons:commons-dbcp2:2.13.0") 52 | testImplementation("io.micrometer:micrometer-tracing-test") 53 | } 54 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/lombok.config: -------------------------------------------------------------------------------- 1 | # This file is generated by the 'io.freefair.lombok' Gradle plugin 2 | config.stopBubbling = true 3 | lombok.addLombokGeneratedAnnotation = true 4 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/model/OutboxLock.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.model; 17 | 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | import lombok.Setter; 21 | import org.springframework.data.annotation.Id; 22 | import org.springframework.data.relational.core.mapping.Table; 23 | 24 | import java.time.Instant; 25 | 26 | @Table("outbox_kafka_lock") 27 | @NoArgsConstructor 28 | @Getter 29 | @Setter 30 | public class OutboxLock { 31 | 32 | // the static value that is used to identify the single possible record in this table - i.e. we make 33 | // use of the uniqueness guarantee of the database to ensure that only a single lock at the same time exists 34 | public static final String OUTBOX_LOCK_ID = "outboxLock"; 35 | 36 | public OutboxLock(String ownerId, Instant validUntil) { 37 | this.ownerId = ownerId; 38 | this.validUntil = validUntil; 39 | } 40 | 41 | @Id 42 | private String id = OUTBOX_LOCK_ID; 43 | 44 | private String ownerId; 45 | 46 | private Instant validUntil; 47 | 48 | } 49 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/model/OutboxRecord.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.model; 17 | 18 | import com.fasterxml.jackson.core.JsonProcessingException; 19 | import com.fasterxml.jackson.core.type.TypeReference; 20 | import com.fasterxml.jackson.databind.ObjectMapper; 21 | import io.r2dbc.postgresql.codec.Json; 22 | import lombok.AllArgsConstructor; 23 | import lombok.Builder; 24 | import lombok.Getter; 25 | import lombok.ToString; 26 | import org.springframework.data.annotation.Id; 27 | import org.springframework.data.annotation.Immutable; 28 | import org.springframework.data.relational.core.mapping.Table; 29 | 30 | import java.io.IOException; 31 | import java.time.Instant; 32 | import java.util.Collections; 33 | import java.util.Map; 34 | 35 | @Table("outbox_kafka") 36 | @Immutable 37 | @AllArgsConstructor 38 | @Builder(toBuilder = true) 39 | @Getter 40 | @ToString(exclude = "value") 41 | public class OutboxRecord { 42 | 43 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 44 | 45 | @Id 46 | private final Long id; 47 | private final Instant created; 48 | private final Instant processed; 49 | private final String topic; 50 | private final String key; 51 | private final byte[] value; 52 | private final Json headers; 53 | 54 | public Map getHeadersAsMap() { 55 | byte[] data = headers == null ? null : headers.asArray(); 56 | if (data == null || data.length == 0) 57 | return Collections.emptyMap(); 58 | 59 | try { 60 | return OBJECT_MAPPER.readValue(data, new TypeReference<>() {}); 61 | } catch (IOException e) { 62 | throw new RuntimeException(e); 63 | } 64 | } 65 | 66 | public static Json toJson(Map headers) { 67 | if (headers == null) 68 | return null; 69 | try { 70 | return Json.of(OBJECT_MAPPER.writeValueAsBytes(headers)); 71 | } catch (JsonProcessingException e) { 72 | throw new IllegalArgumentException("Failed to convert to json: " + headers, e); 73 | } 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/repository/OutboxRepository.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.repository; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 19 | import org.springframework.data.r2dbc.repository.Query; 20 | import org.springframework.data.repository.reactive.ReactiveCrudRepository; 21 | import org.springframework.stereotype.Repository; 22 | import org.springframework.transaction.annotation.Propagation; 23 | import org.springframework.transaction.annotation.Transactional; 24 | import reactor.core.publisher.Flux; 25 | import reactor.core.publisher.Mono; 26 | 27 | import java.time.Instant; 28 | 29 | @Repository 30 | public interface OutboxRepository extends ReactiveCrudRepository { 31 | 32 | @Transactional(propagation = Propagation.REQUIRES_NEW) 33 | default Mono saveInNewTransaction(OutboxRecord entity) { 34 | return save(entity); 35 | } 36 | 37 | /** 38 | * Return all records that have not yet been processed (i.e. that do not have the "processed" timestamp set). 39 | * 40 | * @param limit the max number of records to return 41 | * @return the requested records, sorted by id ascending 42 | */ 43 | @Query("select * from outbox_kafka where processed is null order by id asc limit :limit") 44 | Flux getUnprocessedRecords(int limit); 45 | 46 | Mono deleteOutboxRecordByProcessedNotNullAndProcessedIsBefore(Instant deleteOlderThan); 47 | 48 | } 49 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/service/DefaultKafkaProducerFactory.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.service.OutboxProcessor.KafkaProducerFactory; 19 | import org.apache.kafka.clients.producer.KafkaProducer; 20 | import org.apache.kafka.common.serialization.ByteArraySerializer; 21 | import org.apache.kafka.common.serialization.StringSerializer; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | 25 | import java.util.HashMap; 26 | import java.util.Map; 27 | 28 | import static org.apache.kafka.clients.producer.ProducerConfig.*; 29 | 30 | public class DefaultKafkaProducerFactory implements KafkaProducerFactory { 31 | 32 | private static final Logger logger = LoggerFactory.getLogger(DefaultKafkaProducerFactory.class); 33 | 34 | private final HashMap producerProps; 35 | 36 | public DefaultKafkaProducerFactory(Map producerProps) { 37 | HashMap props = new HashMap<>(producerProps); 38 | // Settings for guaranteed ordering (via enable.idempotence) and dealing with broker failures. 39 | // Note that with `enable.idempotence = true` ordering of messages is also checked by the broker. 40 | if (Boolean.FALSE.equals(props.get(ENABLE_IDEMPOTENCE_CONFIG))) 41 | logger.warn(ENABLE_IDEMPOTENCE_CONFIG + " is set to 'false' - this might lead to out-of-order messages."); 42 | 43 | setIfNotSet(props, ENABLE_IDEMPOTENCE_CONFIG, true); 44 | 45 | // serializer settings 46 | props.put(KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); 47 | props.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); 48 | this.producerProps = props; 49 | } 50 | 51 | private static void setIfNotSet(Map props, String prop, Object value) { 52 | if (!props.containsKey(prop)) props.put(prop, value); 53 | } 54 | 55 | @Override 56 | public KafkaProducer createKafkaProducer() { 57 | return new KafkaProducer<>(producerProps); 58 | } 59 | 60 | @Override 61 | public String toString() { 62 | return "DefaultKafkaProducerFactory{producerProps=" + loggableProducerProps(producerProps) + '}'; 63 | } 64 | 65 | static Map loggableProducerProps(Map producerProps) { 66 | Map maskedProducerProps = new HashMap<>(producerProps); 67 | maskedProducerProps.replaceAll((key, value) -> key.equalsIgnoreCase("sasl.jaas.config") ? "[hidden]" : value); 68 | return maskedProducerProps; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/service/OutboxLockService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import lombok.RequiredArgsConstructor; 19 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxLockRepository; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | import org.springframework.stereotype.Service; 23 | import org.springframework.transaction.reactive.TransactionalOperator; 24 | import reactor.core.publisher.Mono; 25 | 26 | import java.time.Duration; 27 | 28 | @Service 29 | @RequiredArgsConstructor 30 | public class OutboxLockService { 31 | 32 | private static final Logger logger = LoggerFactory.getLogger(OutboxLockService.class); 33 | 34 | private final OutboxLockRepository repository; 35 | private final TransactionalOperator rxtx; 36 | 37 | public Mono acquireOrRefreshLock(String ownerId, Duration lockTimeout, boolean refreshLock) { 38 | return repository.acquireOrRefreshLock(ownerId, lockTimeout, refreshLock); 39 | } 40 | 41 | public Mono releaseLock(String ownerId) { 42 | return repository.releaseLock(ownerId); 43 | } 44 | 45 | @SuppressWarnings("java:S5411") 46 | public Mono runWithLock(String ownerId, Mono action) { 47 | return repository.preventLockStealing(ownerId).flatMap(outboxLockIsPreventedFromLockStealing -> 48 | outboxLockIsPreventedFromLockStealing 49 | ? action.thenReturn(true) 50 | : Mono.just(false) 51 | ).as(rxtx::transactional); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/service/OutboxService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 19 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxRepository; 20 | import one.tomorrow.transactionaloutbox.reactive.tracing.TracingService; 21 | import org.springframework.stereotype.Service; 22 | import org.springframework.transaction.ReactiveTransactionManager; 23 | import org.springframework.transaction.reactive.TransactionalOperator; 24 | import org.springframework.transaction.support.DefaultTransactionDefinition; 25 | import reactor.core.publisher.Mono; 26 | 27 | import java.time.Instant; 28 | import java.util.Map; 29 | 30 | import static one.tomorrow.transactionaloutbox.commons.Maps.merge; 31 | import static one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord.toJson; 32 | import static org.springframework.transaction.TransactionDefinition.PROPAGATION_MANDATORY; 33 | 34 | @Service 35 | public class OutboxService { 36 | 37 | private final OutboxRepository repository; 38 | private final TracingService tracingService; 39 | private final TransactionalOperator mandatoryTxOperator; 40 | 41 | public OutboxService(OutboxRepository repository, ReactiveTransactionManager tm, TracingService tracingService) { 42 | this.repository = repository; 43 | this.tracingService = tracingService; 44 | 45 | DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition(); 46 | txDefinition.setPropagationBehavior(PROPAGATION_MANDATORY); 47 | mandatoryTxOperator = TransactionalOperator.create(tm, txDefinition); 48 | } 49 | 50 | public Mono saveForPublishing(String topic, String key, byte[] event) { 51 | return saveForPublishing(topic, key, event, null); 52 | } 53 | 54 | public Mono saveForPublishing(String topic, String key, byte[] event, Map headerMap) { 55 | Map tracingHeaders = tracingService.tracingHeadersForOutboxRecord(); 56 | Map headers = merge(headerMap, tracingHeaders); 57 | OutboxRecord outboxRecord = OutboxRecord.builder() 58 | .topic(topic) 59 | .key(key) 60 | .value(event) 61 | .headers(toJson(headers)) 62 | .created(Instant.now()) 63 | .build(); 64 | return repository.save(outboxRecord).as(mandatoryTxOperator::transactional); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/service/ProtobufOutboxService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import com.google.protobuf.Message; 19 | import lombok.Getter; 20 | import lombok.RequiredArgsConstructor; 21 | import one.tomorrow.transactionaloutbox.commons.spring.ConditionalOnClass; 22 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 23 | import org.springframework.stereotype.Service; 24 | import reactor.core.publisher.Mono; 25 | 26 | import java.util.Arrays; 27 | import java.util.Map; 28 | import java.util.stream.Collectors; 29 | import java.util.stream.Stream; 30 | 31 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_VALUE_TYPE_NAME; 32 | 33 | @Service 34 | @ConditionalOnClass(Message.class) 35 | public class ProtobufOutboxService { 36 | 37 | private final OutboxService outboxService; 38 | 39 | public ProtobufOutboxService(OutboxService outboxService) { 40 | this.outboxService = outboxService; 41 | } 42 | 43 | /** 44 | * Save the message/event (as byte array), setting the {@link one.tomorrow.transactionaloutbox.commons.KafkaHeaders#HEADERS_VALUE_TYPE_NAME} 45 | * to the fully qualified name of the message descriptor. 46 | */ 47 | public Mono saveForPublishing(String topic, String key, T event, Header...headers) { 48 | Header valueType = new Header(HEADERS_VALUE_TYPE_NAME, event.getDescriptorForType().getFullName()); 49 | Map headerMap = Stream.concat(Stream.of(valueType), Arrays.stream(headers)) 50 | .collect(Collectors.toMap(Header::getKey, Header::getValue)); 51 | return outboxService.saveForPublishing(topic, key, event.toByteArray(), headerMap); 52 | } 53 | 54 | @Getter 55 | @RequiredArgsConstructor 56 | public static class Header { 57 | private final String key; 58 | private final String value; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/tracing/MicrometerTracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.TraceContext; 20 | import io.micrometer.tracing.Tracer; 21 | import io.micrometer.tracing.propagation.Propagator; 22 | import lombok.AllArgsConstructor; 23 | import one.tomorrow.transactionaloutbox.commons.spring.ConditionalOnClass; 24 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 25 | import org.springframework.context.annotation.Primary; 26 | import org.springframework.stereotype.Service; 27 | 28 | import java.util.Collections; 29 | import java.util.HashMap; 30 | import java.util.Map; 31 | import java.util.Map.Entry; 32 | import java.util.Set; 33 | import java.util.concurrent.TimeUnit; 34 | import java.util.stream.Collectors; 35 | 36 | @ConditionalOnClass(Tracer.class) 37 | @Service 38 | @Primary // if this is not good enough, NoopTracingService could use our own implementation of @ConditionalOnMissingBean 39 | @AllArgsConstructor 40 | public class MicrometerTracingService implements TracingService { 41 | 42 | static final String TO_PREFIX = "To_"; 43 | 44 | private final Tracer tracer; 45 | private final Propagator propagator; 46 | 47 | @Override 48 | public Map tracingHeadersForOutboxRecord() { 49 | TraceContext context = tracer.currentTraceContext().context(); 50 | if (context == null) { 51 | return Collections.emptyMap(); 52 | } 53 | Map result = new HashMap<>(); 54 | propagator.inject( 55 | context, 56 | result, 57 | (map, k, v) -> map.put(INTERNAL_PREFIX + k, v) 58 | ); 59 | return result; 60 | } 61 | 62 | @Override 63 | public TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord) { 64 | Map headers = outboxRecord.getHeadersAsMap(); 65 | Set> headerEntries = headers.entrySet(); 66 | boolean containsTraceInfo = headerEntries.stream().anyMatch(e -> e.getKey().startsWith(INTERNAL_PREFIX)); 67 | if (!containsTraceInfo) { 68 | return new HeadersOnlyTraceOutboxRecordProcessingResult(headers); 69 | } 70 | 71 | // This creates a new span with the same trace ID as the parent span 72 | Span outboxSpan = propagator.extract(headers, (map, k) -> map.get(INTERNAL_PREFIX + k)) 73 | .name("transactional-outbox") 74 | .startTimestamp(outboxRecord.getCreated().toEpochMilli(), TimeUnit.MILLISECONDS) 75 | .start(); 76 | outboxSpan.end(); 77 | 78 | Map newHeaders = headerEntries.stream() 79 | .filter(entry -> !entry.getKey().startsWith(INTERNAL_PREFIX)) 80 | .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); 81 | 82 | // the span for publishing to Kafka - this span will be propagated via Kafka, and could be 83 | // referenced by consumers via "follows_from" relationship or set as parent span 84 | Span processingSpan = tracer.spanBuilder() 85 | .setParent(outboxSpan.context()) 86 | .name(TO_PREFIX + outboxRecord.getTopic()) // provides better readability in the UI 87 | .kind(Span.Kind.PRODUCER) 88 | .start(); 89 | 90 | propagator.inject(processingSpan.context(), newHeaders, Map::put); 91 | 92 | return new TraceOutboxRecordProcessingResult(newHeaders) { 93 | @Override 94 | public void publishCompleted() { 95 | processingSpan.end(); 96 | } 97 | @Override 98 | public void publishFailed(Throwable t) { 99 | processingSpan.error(t); 100 | } 101 | }; 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/tracing/NoopTracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 19 | import org.springframework.stereotype.Service; 20 | 21 | import java.util.Collections; 22 | import java.util.Map; 23 | 24 | /** 25 | * A no-op implementation of the {@link TracingService} interface. The MicrometerTracingService 26 | * should be preferred if micrometer-tracing is available on the classpath, therefore it's annotated 27 | * with {@code @Primary}. Alternatively, we could use our own implementation of {@code @ConditionalOnMissingBean} 28 | * and use this class as the default/fallback implementation. 29 | */ 30 | @Service 31 | public class NoopTracingService implements TracingService { 32 | 33 | @Override 34 | public Map tracingHeadersForOutboxRecord() { 35 | return Collections.emptyMap(); 36 | } 37 | 38 | @Override 39 | public TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord) { 40 | return new HeadersOnlyTraceOutboxRecordProcessingResult(outboxRecord.getHeadersAsMap()); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/main/java/one/tomorrow/transactionaloutbox/reactive/tracing/TracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import lombok.Data; 19 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 20 | 21 | import java.util.Map; 22 | 23 | public interface TracingService { 24 | 25 | String INTERNAL_PREFIX = "_internal_:"; 26 | 27 | /** 28 | * Extracts the tracing headers from the current context and returns them as a map. 29 | * If tracing is not active, an empty map is returned. 30 | *

31 | * This is meant to be used when creating an outbox record, to store the tracing headers with the record. 32 | *

33 | */ 34 | Map tracingHeadersForOutboxRecord(); 35 | 36 | /** 37 | * Extracts the tracing headers (as created via {@link #tracingHeadersForOutboxRecord()}) from the outbox record 38 | * to create a span for the time spent in the outbox.
39 | * A new span is started for the processing and publishing to Kafka, and headers to publish to Kafka are returned. 40 | * The span must be completed once the message is published to Kafka. 41 | */ 42 | TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord); 43 | 44 | @Data 45 | abstract class TraceOutboxRecordProcessingResult { 46 | 47 | private final Map headers; 48 | 49 | /** Must be invoked once the outbox record was successfully sent to Kafka */ 50 | public abstract void publishCompleted(); 51 | /** Must be invoked if the outbox record could not be sent to Kafka */ 52 | public abstract void publishFailed(Throwable t); 53 | 54 | } 55 | 56 | class HeadersOnlyTraceOutboxRecordProcessingResult extends TraceOutboxRecordProcessingResult { 57 | public HeadersOnlyTraceOutboxRecordProcessingResult(Map headers) { 58 | super(headers); 59 | } 60 | 61 | @Override 62 | public void publishCompleted() { 63 | // no-op 64 | } 65 | 66 | @Override 67 | public void publishFailed(Throwable t) { 68 | // no-op 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/AbstractIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive; 17 | 18 | import one.tomorrow.transactionaloutbox.commons.ProxiedKafkaContainer; 19 | import one.tomorrow.transactionaloutbox.commons.ProxiedPostgreSQLContainer; 20 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxLock; 21 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxLockRepository; 22 | import one.tomorrow.transactionaloutbox.reactive.service.OutboxLockService; 23 | import org.flywaydb.test.junit5.annotation.FlywayTestExtension; 24 | import org.junit.jupiter.api.extension.ExtendWith; 25 | import org.slf4j.Logger; 26 | import org.slf4j.LoggerFactory; 27 | import org.springframework.test.context.ActiveProfiles; 28 | import org.springframework.test.context.ContextConfiguration; 29 | import org.springframework.test.context.DynamicPropertyRegistry; 30 | import org.springframework.test.context.DynamicPropertySource; 31 | import org.springframework.test.context.junit.jupiter.SpringExtension; 32 | import org.testcontainers.junit.jupiter.Testcontainers; 33 | 34 | @ExtendWith(SpringExtension.class) 35 | @ActiveProfiles("test") 36 | @ContextConfiguration(classes = { 37 | OutboxLockRepository.class, 38 | OutboxLock.class, 39 | OutboxLockService.class, 40 | IntegrationTestConfig.class 41 | }) 42 | @Testcontainers 43 | @FlywayTestExtension 44 | public abstract class AbstractIntegrationTest { 45 | 46 | public static ProxiedPostgreSQLContainer postgresqlContainer = ProxiedPostgreSQLContainer.startProxiedPostgres(); 47 | public static final ProxiedKafkaContainer kafkaContainer = ProxiedKafkaContainer.startProxiedKafka(); 48 | 49 | protected Logger logger = LoggerFactory.getLogger(getClass()); 50 | 51 | @DynamicPropertySource 52 | public static void setR2DBCProperties(DynamicPropertyRegistry registry) { 53 | ProxiedPostgreSQLContainer.setConnectionProperties(registry); 54 | registry.add("spring.r2dbc.pool.enabled", () -> "true"); 55 | registry.add("spring.r2dbc.pool.initial-size", () -> "5"); 56 | registry.add("spring.r2dbc.pool.max-size", () -> "10"); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/IntegrationTestConfig.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive; 17 | 18 | import org.apache.commons.dbcp2.BasicDataSource; 19 | import org.flywaydb.core.Flyway; 20 | import org.flywaydb.core.api.configuration.ClassicConfiguration; 21 | import org.flywaydb.test.FlywayHelperFactory; 22 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 23 | import org.springframework.context.annotation.Bean; 24 | import org.springframework.context.annotation.Configuration; 25 | import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; 26 | import org.springframework.transaction.annotation.EnableTransactionManagement; 27 | 28 | import javax.sql.DataSource; 29 | import java.time.Duration; 30 | import java.util.Properties; 31 | 32 | import static one.tomorrow.transactionaloutbox.reactive.AbstractIntegrationTest.postgresqlContainer; 33 | 34 | @Configuration 35 | @EnableAutoConfiguration 36 | @EnableTransactionManagement 37 | @EnableR2dbcRepositories 38 | public class IntegrationTestConfig { 39 | 40 | public static final Duration DEFAULT_OUTBOX_LOCK_TIMEOUT = Duration.ofMillis(200); 41 | 42 | @Bean 43 | public DataSource dataSource() { 44 | BasicDataSource dataSource = new BasicDataSource(); 45 | 46 | dataSource.setDriverClassName(postgresqlContainer.getDriverClassName()); 47 | dataSource.setUrl(postgresqlContainer.getJdbcUrl()); 48 | dataSource.setUsername(postgresqlContainer.getUsername()); 49 | dataSource.setPassword(postgresqlContainer.getPassword()); 50 | 51 | return dataSource; 52 | } 53 | 54 | @Bean 55 | public Flyway flywayFactory(ClassicConfiguration configuration) { 56 | FlywayHelperFactory factory = new FlywayHelperFactory(); 57 | 58 | factory.setFlywayConfiguration(configuration); 59 | factory.setFlywayProperties(new Properties()); 60 | 61 | return factory.createFlyway(); 62 | } 63 | 64 | @Bean 65 | public ClassicConfiguration flywayConfiguration(DataSource dataSource) { 66 | ClassicConfiguration configuration = new ClassicConfiguration(); 67 | 68 | configuration.setDataSource(dataSource); 69 | configuration.setLocationsAsStrings("classpath:/db/migration"); 70 | configuration.setCleanDisabled(false); 71 | 72 | return configuration; 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/KafkaTestSupport.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive; 17 | 18 | import one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport; 19 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 20 | import one.tomorrow.transactionaloutbox.reactive.service.DefaultKafkaProducerFactory; 21 | import one.tomorrow.transactionaloutbox.reactive.tracing.TracingService; 22 | import org.apache.kafka.clients.consumer.ConsumerRecord; 23 | 24 | import java.util.Map; 25 | 26 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.producerProps; 27 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_SEQUENCE_NAME; 28 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_SOURCE_NAME; 29 | import static one.tomorrow.transactionaloutbox.commons.Longs.toLong; 30 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 31 | import static org.junit.jupiter.api.Assertions.assertEquals; 32 | 33 | public interface KafkaTestSupport extends CommonKafkaTestSupport { 34 | 35 | static DefaultKafkaProducerFactory producerFactory() { 36 | return producerFactory(producerProps()); 37 | } 38 | 39 | static DefaultKafkaProducerFactory producerFactory(Map producerProps) { 40 | return new DefaultKafkaProducerFactory(producerProps); 41 | } 42 | 43 | static void assertConsumedRecord(OutboxRecord outboxRecord, String sourceHeaderValue, ConsumerRecord kafkaRecord) { 44 | assertEquals( 45 | outboxRecord.getId().longValue(), 46 | toLong(kafkaRecord.headers().lastHeader(HEADERS_SEQUENCE_NAME).value()), 47 | "OutboxRecord id and " + HEADERS_SEQUENCE_NAME + " headers do not match" 48 | ); 49 | assertArrayEquals(sourceHeaderValue.getBytes(), kafkaRecord.headers().lastHeader(HEADERS_SOURCE_NAME).value()); 50 | outboxRecord.getHeadersAsMap().forEach((key, value) -> { 51 | if (!key.startsWith(TracingService.INTERNAL_PREFIX)) 52 | assertArrayEquals(value.getBytes(), kafkaRecord.headers().lastHeader(key).value()); 53 | }); 54 | assertEquals(outboxRecord.getKey(), kafkaRecord.key()); 55 | assertArrayEquals(outboxRecord.getValue(), kafkaRecord.value()); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/TestUtils.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive; 17 | 18 | import io.r2dbc.postgresql.codec.Json; 19 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 20 | 21 | import java.time.Instant; 22 | import java.util.Map; 23 | import java.util.Random; 24 | 25 | public class TestUtils { 26 | 27 | private static final Random RANDOM = new Random(); 28 | 29 | public static boolean randomBoolean() { 30 | return RANDOM.nextBoolean(); 31 | } 32 | 33 | public static int randomInt(int bound) { 34 | return RANDOM.nextInt(bound); 35 | } 36 | 37 | public static OutboxRecord newRecord(String topic, String key, String value) { 38 | return newRecord(topic, key, value, null); 39 | } 40 | 41 | public static OutboxRecord newRecord(String topic, String key, String value, Map headers) { 42 | return newRecord(null, topic, key, value, headers); 43 | } 44 | 45 | public static OutboxRecord newRecord(Instant processed, String topic, String key, String value, Map headers) { 46 | Json json = OutboxRecord.toJson(headers); 47 | return new OutboxRecord( 48 | null, 49 | Instant.now(), 50 | processed, 51 | topic, 52 | key, 53 | value.getBytes(), 54 | json 55 | ); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/repository/OutboxRepositoryIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.repository; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.AbstractIntegrationTest; 19 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 20 | import org.flywaydb.test.annotation.FlywayTest; 21 | import org.junit.jupiter.api.Test; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | 24 | import java.time.Duration; 25 | import java.time.Instant; 26 | import java.util.Collections; 27 | import java.util.List; 28 | import java.util.Map; 29 | 30 | import static java.time.temporal.ChronoUnit.MILLIS; 31 | import static one.tomorrow.transactionaloutbox.reactive.TestUtils.newRecord; 32 | import static org.hamcrest.CoreMatchers.*; 33 | import static org.hamcrest.MatcherAssert.assertThat; 34 | import static org.hamcrest.beans.SamePropertyValuesAs.samePropertyValuesAs; 35 | 36 | @FlywayTest 37 | @SuppressWarnings("ConstantConditions") 38 | class OutboxRepositoryIntegrationTest extends AbstractIntegrationTest { 39 | 40 | @Autowired 41 | private OutboxRepository testee; 42 | 43 | @Test 44 | void should_UpdateRecord() { 45 | // given 46 | OutboxRecord record = testee.save( 47 | newRecord("topic1", "key1", "value1", Map.of("h1", "v1")) 48 | ).block(); 49 | assertThat(record.getProcessed(), is(nullValue())); 50 | 51 | // when 52 | OutboxRecord update = record.toBuilder().processed(Instant.now()).build(); 53 | testee.save(update).block(); 54 | 55 | // then 56 | OutboxRecord foundUpdate = testee.findById(record.getId()).block(); 57 | assertThat(foundUpdate.getProcessed(), is(notNullValue())); 58 | } 59 | 60 | @Test 61 | void should_FindUnprocessedRecords() { 62 | // given 63 | testee.save( 64 | newRecord(Instant.now(),"topic1", "key1", "value1", Map.of("h1", "v1")) 65 | ).block(); 66 | 67 | OutboxRecord record2 = testee.save( 68 | newRecord("topic2", "key2", "value2", Map.of("h2", "v2")) 69 | ).block(); 70 | 71 | // when 72 | List result = testee.getUnprocessedRecords(100).collectList().block(); 73 | 74 | // then 75 | assertThat(result.size(), is(1)); 76 | OutboxRecord foundRecord = result.get(0); 77 | // ignore created, because for the found record it's truncated to micros 78 | // ignore headers, because Json doesn't implement equals 79 | assertThat(foundRecord, samePropertyValuesAs(record2, "created", "headers")); 80 | assertThat(foundRecord.getCreated().truncatedTo(MILLIS), is(equalTo(record2.getCreated().truncatedTo(MILLIS)))); 81 | assertThat(foundRecord.getHeadersAsMap(), is(equalTo(record2.getHeadersAsMap()))); 82 | } 83 | 84 | @Test 85 | void shouldDeleteOlderProcessedRecords() { 86 | // given 87 | OutboxRecord shouldBeKeptAsNotProcessed = testee.save( 88 | newRecord(null, "topic1", "key1", "value1", Collections.emptyMap()) 89 | ) 90 | .block(); 91 | 92 | OutboxRecord shouldBeKeptAsNotInDeletionPeriod = testee.save( 93 | newRecord(Instant.now().minus(Duration.ofDays(1)), "topic1", "key1", "value3", Collections.emptyMap()) 94 | ) 95 | .block(); 96 | 97 | OutboxRecord shouldBeDeleted1 = testee.save(newRecord(Instant.now().minus(Duration.ofDays(16)), "topic1", "key1", "value1", Collections.emptyMap())) 98 | .block(); 99 | 100 | OutboxRecord shouldBeDeleted2 = testee.save(newRecord(Instant.now().minus(Duration.ofDays(18)), "topic1", "key1", "value2", Collections.emptyMap())) 101 | .block(); 102 | 103 | // when 104 | Long deletedRows = testee.deleteOutboxRecordByProcessedNotNullAndProcessedIsBefore(Instant.now().minus(Duration.ofDays(15))).block(); 105 | 106 | // then 107 | assertThat(deletedRows, is(2L)); 108 | assertThat(testee.existsById(shouldBeKeptAsNotProcessed.getId()).block(), is(true)); 109 | assertThat(testee.existsById(shouldBeKeptAsNotInDeletionPeriod.getId()).block(), is(true)); 110 | assertThat(testee.existsById(shouldBeDeleted1.getId()).block(), is(false)); 111 | assertThat(testee.existsById(shouldBeDeleted2.getId()).block(), is(false)); 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/service/ConcurrentOutboxProcessorsIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.AbstractIntegrationTest; 19 | import one.tomorrow.transactionaloutbox.reactive.KafkaTestSupport; 20 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 21 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxRepository; 22 | import org.apache.kafka.clients.consumer.Consumer; 23 | import org.apache.kafka.clients.consumer.ConsumerRecord; 24 | import org.flywaydb.test.annotation.FlywayTest; 25 | import org.junit.jupiter.api.AfterAll; 26 | import org.junit.jupiter.api.AfterEach; 27 | import org.junit.jupiter.api.BeforeAll; 28 | import org.junit.jupiter.api.Test; 29 | import org.springframework.beans.factory.annotation.Autowired; 30 | 31 | import java.time.Duration; 32 | import java.util.Iterator; 33 | import java.util.List; 34 | import java.util.Map; 35 | 36 | import static java.util.stream.IntStream.range; 37 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.*; 38 | import static one.tomorrow.transactionaloutbox.reactive.KafkaTestSupport.*; 39 | import static one.tomorrow.transactionaloutbox.commons.ProxiedKafkaContainer.bootstrapServers; 40 | import static one.tomorrow.transactionaloutbox.reactive.TestUtils.newRecord; 41 | 42 | @FlywayTest 43 | @SuppressWarnings({"unused"}) 44 | class ConcurrentOutboxProcessorsIntegrationTest extends AbstractIntegrationTest implements KafkaTestSupport { 45 | 46 | private static final String topic = "topicConcurrentTest"; 47 | private static Consumer consumer; 48 | 49 | @Autowired 50 | private OutboxRepository repository; 51 | @Autowired 52 | private OutboxLockService lockService; 53 | 54 | private OutboxProcessor testee1; 55 | private OutboxProcessor testee2; 56 | 57 | @BeforeAll 58 | public static void beforeAll() { 59 | createTopic(bootstrapServers, topic); 60 | } 61 | 62 | @AfterAll 63 | public static void afterAll() { 64 | if (consumer != null) 65 | consumer.close(); 66 | } 67 | 68 | @AfterEach 69 | public void afterTest() { 70 | testee1.close(); 71 | testee2.close(); 72 | } 73 | 74 | @Test 75 | void should_processRecordsOnceInOrder() { 76 | // given 77 | Duration lockTimeout = Duration.ofMillis(20); // very aggressive lock stealing 78 | Duration processingInterval = Duration.ZERO; 79 | String eventSource = "test"; 80 | testee1 = new OutboxProcessor(repository, lockService, producerFactory(), processingInterval, lockTimeout, "processor1", eventSource); 81 | testee2 = new OutboxProcessor(repository, lockService, producerFactory(), processingInterval, lockTimeout, "processor2", eventSource); 82 | 83 | // when 84 | List outboxRecords = range(0, 1000).mapToObj(i -> 85 | repository.save(newRecord(topic, "key1", "value" + i, Map.of("h", "v" + i))).block() 86 | ).toList(); 87 | 88 | // then 89 | Iterator> kafkaRecords = getAndCommitRecords(outboxRecords.size()).iterator(); 90 | outboxRecords.forEach(outboxRecord -> 91 | assertConsumedRecord(outboxRecord, eventSource, kafkaRecords.next()) 92 | ); 93 | } 94 | 95 | @Override 96 | public Consumer consumer() { 97 | if (consumer == null) { 98 | consumer = createConsumer(bootstrapServers); 99 | consumer.subscribe(List.of(topic)); 100 | } 101 | return consumer; 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/service/DefaultKafkaProducerFactoryTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Tomorrow GmbH @ https://tomorrow.one 3 | *

4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | *

8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | *

10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import org.junit.Test; 19 | 20 | import java.util.Map; 21 | 22 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.producerProps; 23 | import static org.apache.kafka.clients.producer.ProducerConfig.BOOTSTRAP_SERVERS_CONFIG; 24 | import static org.apache.kafka.common.config.SaslConfigs.SASL_JAAS_CONFIG; 25 | import static org.junit.Assert.assertEquals; 26 | 27 | @SuppressWarnings("unchecked") 28 | public class DefaultKafkaProducerFactoryTest { 29 | 30 | @Test 31 | public void should_buildLoggableProducerWithoutSensitiveContent() { 32 | // given 33 | Map producerProps = producerProps("bootstrapServers"); 34 | String saslJaasConfig = "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"abc-backend-user\" password=\"xyz\";"; 35 | producerProps.put(SASL_JAAS_CONFIG, saslJaasConfig); 36 | 37 | // when 38 | Map loggableProducerProps = DefaultKafkaProducerFactory.loggableProducerProps(producerProps); 39 | 40 | // then 41 | assertEquals("[hidden]", loggableProducerProps.get(SASL_JAAS_CONFIG)); 42 | assertEquals("bootstrapServers", loggableProducerProps.get(BOOTSTRAP_SERVERS_CONFIG)); 43 | 44 | // make sure we don't change the original values by side effect 45 | assertEquals(saslJaasConfig, producerProps.get(SASL_JAAS_CONFIG)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/service/OutboxLockServiceIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.AbstractIntegrationTest; 19 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxLockRepository; 20 | import org.flywaydb.test.annotation.FlywayTest; 21 | import org.junit.jupiter.api.AfterAll; 22 | import org.junit.jupiter.api.BeforeAll; 23 | import org.junit.jupiter.api.Test; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | import org.springframework.beans.factory.annotation.Autowired; 27 | import org.springframework.transaction.reactive.TransactionalOperator; 28 | import reactor.core.publisher.Mono; 29 | 30 | import java.time.Duration; 31 | import java.util.concurrent.*; 32 | 33 | import reactor.test.StepVerifier; 34 | 35 | import static java.util.concurrent.TimeUnit.SECONDS; 36 | import static one.tomorrow.transactionaloutbox.reactive.TestUtils.randomBoolean; 37 | import static org.hamcrest.CoreMatchers.is; 38 | import static org.hamcrest.MatcherAssert.assertThat; 39 | 40 | @FlywayTest 41 | @SuppressWarnings({"unused", "ConstantConditions"}) 42 | class OutboxLockServiceIntegrationTest extends AbstractIntegrationTest { 43 | 44 | private static final Logger logger = LoggerFactory.getLogger(OutboxLockServiceIntegrationTest.class); 45 | 46 | @Autowired 47 | private OutboxLockRepository lockRepository; 48 | @Autowired 49 | private TransactionalOperator rxtx; 50 | 51 | private static ExecutorService executorService; 52 | 53 | @BeforeAll 54 | public static void beforeClass() { 55 | executorService = Executors.newCachedThreadPool(); 56 | } 57 | 58 | @AfterAll 59 | public static void afterClass() { 60 | executorService.shutdown(); 61 | } 62 | 63 | @Test 64 | void runWithLock_should_preventLockStealing() throws ExecutionException, InterruptedException, TimeoutException { 65 | // given 66 | String ownerId1 = "owner-1"; 67 | String ownerId2 = "owner-2"; 68 | Duration lockTimeout = Duration.ZERO; 69 | OutboxLockService lockService = new OutboxLockService(lockRepository, rxtx); 70 | 71 | boolean locked = lockService.acquireOrRefreshLock(ownerId1, lockTimeout, randomBoolean()).block(); 72 | assertThat(locked, is(true)); 73 | 74 | CyclicBarrier barrier1 = new CyclicBarrier(2); 75 | 76 | // use CompletableFuture + Mono, because blocking on Bariers inside deferred monos would block the IO thread 77 | // and in consequence the app would be locked (and test fail) 78 | CompletableFuture barrier2CompletionStage = new CompletableFuture<>(); 79 | Mono barrier2Mono = Mono.fromCompletionStage(barrier2CompletionStage); 80 | 81 | CompletableFuture barrier3CompletionStage = new CompletableFuture<>(); 82 | Mono barrier3Mono = Mono.fromCompletionStage(barrier3CompletionStage); 83 | 84 | // when 85 | Future runWithLockResult = executorService.submit(() -> { 86 | await(barrier1); 87 | return lockService.runWithLock(ownerId1, Mono.defer(() -> { 88 | barrier2CompletionStage.complete(null); // start owner2 acquireOrRefreshLock not before owner1 is inside "runWithLock" 89 | return barrier3Mono; 90 | })).block(); 91 | }); 92 | Future lockStealingAttemptResult = executorService.submit(() -> { 93 | await(barrier1); 94 | barrier2Mono.block(); // start acquireOrRefreshLock not before owner1 is inside "runWithLock" 95 | boolean result = lockService.acquireOrRefreshLock(ownerId2, lockTimeout, randomBoolean()).block(); 96 | barrier3CompletionStage.complete(null); // let owner1 runWithLock action complete 97 | return result; 98 | }); 99 | 100 | // then 101 | assertThat(runWithLockResult.get(5, SECONDS), is(true)); 102 | assertThat(lockStealingAttemptResult.get(5, SECONDS), is(false)); 103 | } 104 | 105 | @Test 106 | void runWithLock_should_returnError_from_callback() throws ExecutionException, InterruptedException, TimeoutException { 107 | // given 108 | String ownerId = "owner-1"; 109 | OutboxLockService lockService = new OutboxLockService(lockRepository, rxtx); 110 | boolean locked = lockService.acquireOrRefreshLock(ownerId, Duration.ZERO, randomBoolean()).block(); 111 | assertThat(locked, is(true)); 112 | 113 | // when 114 | Mono runWithLockResult = lockService.runWithLock(ownerId, Mono.error(new RuntimeException("simulated error"))); 115 | 116 | // then 117 | StepVerifier.create(runWithLockResult) 118 | .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().equals("simulated error")) 119 | .verify(); 120 | } 121 | 122 | /** Awaits the given barrier, turning checked exceptions into unchecked, for easier usage in lambdas. */ 123 | private void await(CyclicBarrier barrier) { 124 | try { 125 | barrier.await(5, SECONDS); 126 | } catch (Exception e) { 127 | throw new RuntimeException(e); 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/service/ProtobufOutboxServiceIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.service; 17 | 18 | import one.tomorrow.transactionaloutbox.reactive.AbstractIntegrationTest; 19 | import one.tomorrow.transactionaloutbox.reactive.IntegrationTestConfig; 20 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxLock; 21 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 22 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxLockRepository; 23 | import one.tomorrow.transactionaloutbox.reactive.repository.OutboxRepository; 24 | import one.tomorrow.transactionaloutbox.reactive.test.Sample.SomethingHappened; 25 | import one.tomorrow.transactionaloutbox.reactive.tracing.MicrometerTracingIntegrationTestConfig; 26 | import org.flywaydb.test.annotation.FlywayTest; 27 | import org.junit.jupiter.api.AfterEach; 28 | import org.junit.jupiter.api.Test; 29 | import org.slf4j.Logger; 30 | import org.slf4j.LoggerFactory; 31 | import org.springframework.beans.factory.annotation.Autowired; 32 | import org.springframework.test.context.ContextConfiguration; 33 | import org.springframework.transaction.IllegalTransactionStateException; 34 | import org.springframework.transaction.reactive.TransactionalOperator; 35 | import reactor.core.publisher.Mono; 36 | import reactor.test.StepVerifier; 37 | 38 | import static org.hamcrest.CoreMatchers.is; 39 | import static org.hamcrest.CoreMatchers.notNullValue; 40 | import static org.hamcrest.MatcherAssert.assertThat; 41 | import static org.hamcrest.collection.IsMapContaining.hasEntry; 42 | import static org.hamcrest.collection.IsMapContaining.hasKey; 43 | 44 | @ContextConfiguration(classes = { 45 | OutboxLockRepository.class, 46 | OutboxLock.class, 47 | OutboxLockService.class, 48 | OutboxService.class, 49 | ProtobufOutboxService.class, 50 | IntegrationTestConfig.class, 51 | MicrometerTracingIntegrationTestConfig.class 52 | }) 53 | @FlywayTest 54 | @SuppressWarnings({"unused", "ConstantConditions"}) 55 | class ProtobufOutboxServiceIntegrationTest extends AbstractIntegrationTest { 56 | 57 | private static final Logger logger = LoggerFactory.getLogger(ProtobufOutboxServiceIntegrationTest.class); 58 | 59 | @Autowired 60 | private ProtobufOutboxService testee; 61 | @Autowired 62 | private OutboxRepository repository; 63 | @Autowired 64 | private TransactionalOperator rxtx; 65 | 66 | @AfterEach 67 | public void cleanUp() { 68 | repository.deleteAll().block(); 69 | } 70 | 71 | @Test 72 | void should_failOnMissingTransaction() { 73 | // given 74 | SomethingHappened message = SomethingHappened.newBuilder().setId(1).setName("foo").build(); 75 | 76 | // when 77 | Mono result = testee.saveForPublishing("protobuf_topic", "key", message); 78 | 79 | // then 80 | StepVerifier.create(result) 81 | .expectError(IllegalTransactionStateException.class) 82 | .verify(); 83 | } 84 | 85 | @Test 86 | void should_save_withExistingTransaction() { 87 | // given 88 | SomethingHappened message = SomethingHappened.newBuilder().setId(1).setName("foo").build(); 89 | 90 | // when 91 | Mono result = testee.saveForPublishing("protobuf_topic", "key", message) 92 | .as(rxtx::transactional); 93 | 94 | // then 95 | OutboxRecord savedRecord = result.block(); 96 | assertThat(savedRecord.getId(), is(notNullValue())); 97 | 98 | OutboxRecord foundRecord = repository.findById(savedRecord.getId()).block(); 99 | assertThat(foundRecord, is(notNullValue())); 100 | } 101 | 102 | @Test 103 | void should_save_withAdditionalHeader() { 104 | // given 105 | SomethingHappened message = SomethingHappened.newBuilder().setId(1).setName("foo").build(); 106 | ProtobufOutboxService.Header additionalHeader = new ProtobufOutboxService.Header("key", "value"); 107 | 108 | // when 109 | Mono result = testee.saveForPublishing("topic", "key", message, additionalHeader) 110 | .as(rxtx::transactional); 111 | 112 | // then 113 | OutboxRecord savedRecord = result.block(); 114 | assertThat(savedRecord.getId(), is(notNullValue())); 115 | 116 | OutboxRecord foundRecord = repository.findById(savedRecord.getId()).block(); 117 | assertThat(foundRecord, is(notNullValue())); 118 | assertThat(foundRecord.getHeadersAsMap(), hasEntry(additionalHeader.getKey(), additionalHeader.getValue())); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/tracing/MicrometerTracingIntegrationTestConfig.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import io.micrometer.observation.Observation; 19 | import io.micrometer.observation.ObservationRegistry; 20 | import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; 21 | import io.micrometer.tracing.Tracer; 22 | import io.micrometer.tracing.handler.DefaultTracingObservationHandler; 23 | import io.micrometer.tracing.handler.TracingObservationHandler; 24 | import io.micrometer.tracing.propagation.Propagator; 25 | import io.micrometer.tracing.test.simple.SimpleTracer; 26 | import org.springframework.context.annotation.Bean; 27 | import org.springframework.context.annotation.Configuration; 28 | import reactor.core.publisher.Hooks; 29 | 30 | @Configuration 31 | public class MicrometerTracingIntegrationTestConfig { 32 | 33 | static { 34 | Hooks.enableAutomaticContextPropagation(); 35 | } 36 | 37 | @Bean 38 | public TracingObservationHandler tracingObservationHandler(Tracer tracer) { 39 | return new DefaultTracingObservationHandler(tracer); 40 | } 41 | 42 | @Bean 43 | public ObservationRegistry observationRegistry(TracingObservationHandler tracingObservationHandler) { 44 | ObservationRegistry registry = ObservationRegistry.create(); 45 | registry.observationConfig().observationHandler(tracingObservationHandler); 46 | 47 | // From https://docs.micrometer.io/micrometer/reference/observation/instrumenting.html#instrumentation_of_reactive_libraries: 48 | // Starting from Micrometer 1.10.8 you need to set your registry on this singleton instance of OTLA 49 | ObservationThreadLocalAccessor.getInstance().setObservationRegistry(registry); 50 | 51 | return registry; 52 | } 53 | 54 | @Bean 55 | public SimpleTracer simpleTracer() { 56 | return new SimpleTracer(); 57 | } 58 | 59 | @Bean 60 | public Propagator propagator(SimpleTracer tracer) { 61 | return new SimplePropagator(tracer); 62 | } 63 | 64 | @Bean 65 | public TracingService tracingService(SimpleTracer tracer, Propagator propagator) { 66 | return new MicrometerTracingService(tracer, propagator); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/tracing/MicrometerTracingServiceTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.Tracer.SpanInScope; 20 | import io.micrometer.tracing.test.simple.SimpleSpan; 21 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 22 | import io.micrometer.tracing.test.simple.SimpleTracer; 23 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 24 | import one.tomorrow.transactionaloutbox.reactive.tracing.TracingService.TraceOutboxRecordProcessingResult; 25 | import org.junit.jupiter.api.BeforeEach; 26 | import org.junit.jupiter.api.Test; 27 | 28 | import java.time.Instant; 29 | import java.util.Map; 30 | 31 | import static java.time.temporal.ChronoUnit.MILLIS; 32 | import static one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord.toJson; 33 | import static one.tomorrow.transactionaloutbox.reactive.tracing.SimplePropagator.TRACING_SPAN_ID; 34 | import static one.tomorrow.transactionaloutbox.reactive.tracing.SimplePropagator.TRACING_TRACE_ID; 35 | import static org.hamcrest.MatcherAssert.assertThat; 36 | import static org.hamcrest.Matchers.*; 37 | 38 | class MicrometerTracingServiceTest implements TracingAssertions { 39 | 40 | private SimpleTracer tracer; 41 | private MicrometerTracingService micrometerTracingService; 42 | 43 | @BeforeEach 44 | void setUp() { 45 | tracer = new SimpleTracer(); 46 | micrometerTracingService = new MicrometerTracingService(tracer, new SimplePropagator(tracer)); 47 | } 48 | 49 | @Test 50 | void tracingHeadersForOutboxRecord_withoutActiveTraceContext_returnsEmptyMap() { 51 | Map headers = micrometerTracingService.tracingHeadersForOutboxRecord(); 52 | assertThat(headers, is(anEmptyMap())); 53 | } 54 | 55 | @Test 56 | void tracingHeadersForOutboxRecord_withActiveTraceContext_returnsHeaders() { 57 | Span span = tracer.nextSpan().name("test-span").start(); 58 | try (SpanInScope ignored = tracer.withSpan(span)) { 59 | Map headers = micrometerTracingService.tracingHeadersForOutboxRecord(); 60 | assertThat(headers, is(not(anEmptyMap()))); 61 | assertThat(headers.get("_internal_:" + TRACING_TRACE_ID), is(equalTo(span.context().traceId()))); 62 | assertThat(headers.get("_internal_:" + TRACING_SPAN_ID), is(equalTo(span.context().spanId()))); 63 | } finally { 64 | span.end(); 65 | } 66 | } 67 | 68 | @Test 69 | void traceOutboxRecordProcessing_withValidOutboxRecord_createsAndEndsSpan() { 70 | String traceId = "traceId1"; 71 | String spanId = "spanId1"; 72 | OutboxRecord outboxRecord = OutboxRecord.builder() 73 | .created(Instant.now().minusMillis(42)) 74 | .headers(toJson(Map.of( 75 | "some", "header", 76 | "_internal_:" + TRACING_TRACE_ID, traceId, 77 | "_internal_:" + TRACING_SPAN_ID, spanId))) 78 | .build(); 79 | 80 | TraceOutboxRecordProcessingResult result = micrometerTracingService.traceOutboxRecordProcessing(outboxRecord); 81 | 82 | // verify recorded span for the outbox record in the transactional-outbox 83 | assertThat(tracer.getSpans(), hasSize(2)); // one for the transactional-outbox and one for the processing to Kafka 84 | SimpleSpan outboxSpan = tracer.getSpans().getFirst(); 85 | assertOutboxSpan(outboxSpan, traceId, spanId, outboxRecord); 86 | 87 | // verify recorded span for the processing to Kafka 88 | SimpleSpan processingSpan = tracer.getSpans().getLast(); 89 | SimpleTraceContext processingSpanContext = processingSpan.context(); 90 | assertProcessingSpan(processingSpanContext, traceId, outboxSpan.context().spanId()); 91 | 92 | // verify returned headers 93 | Map headers = result.getHeaders(); 94 | assertThat(headers, hasEntry("some", "header")); 95 | assertThat(headers, hasEntry(TRACING_TRACE_ID, traceId)); 96 | assertThat(headers, hasEntry(TRACING_SPAN_ID, processingSpanContext.spanId())); 97 | 98 | // verify that the processing span is ended correctly 99 | // initially the end timespan is 100 | assertThat(processingSpan.getEndTimestamp(), is(equalTo(Instant.ofEpochMilli(0L)))); 101 | Instant before = Instant.now().truncatedTo(MILLIS); 102 | result.publishCompleted(); 103 | Instant after = Instant.now().truncatedTo(MILLIS); 104 | assertThat(processingSpan.getEndTimestamp(), is(greaterThanOrEqualTo(before))); 105 | assertThat(processingSpan.getEndTimestamp(), is(lessThanOrEqualTo(after))); 106 | } 107 | 108 | @Test 109 | void traceOutboxRecordProcessing_withoutTraceHeaders_ignoresTracing() { 110 | OutboxRecord outboxRecord = OutboxRecord.builder() 111 | .created(Instant.now()) 112 | .headers(toJson(Map.of("some", "header"))) 113 | .build(); 114 | 115 | TraceOutboxRecordProcessingResult result = micrometerTracingService.traceOutboxRecordProcessing(outboxRecord); 116 | 117 | assertThat(tracer.getSpans(), is(empty())); 118 | Map headers = result.getHeaders(); 119 | assertThat(headers, is(aMapWithSize(1))); 120 | assertThat(headers.get("some"), is(equalTo("header"))); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/tracing/SimplePropagator.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.TraceContext; 20 | import io.micrometer.tracing.propagation.Propagator; 21 | import io.micrometer.tracing.test.simple.SimpleSpanBuilder; 22 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 23 | import io.micrometer.tracing.test.simple.SimpleTracer; 24 | import lombok.RequiredArgsConstructor; 25 | import org.jetbrains.annotations.NotNull; 26 | 27 | import java.util.List; 28 | 29 | @RequiredArgsConstructor 30 | public class SimplePropagator implements Propagator { 31 | 32 | public static final String TRACING_TRACE_ID = "traceId"; 33 | public static final String TRACING_SPAN_ID = "spanId"; 34 | private final SimpleTracer tracer; 35 | 36 | @Override 37 | @NotNull 38 | public List fields() { 39 | return List.of(TRACING_TRACE_ID, TRACING_SPAN_ID); 40 | } 41 | 42 | @Override 43 | public void inject(TraceContext context, C carrier, Setter setter) { 44 | setter.set(carrier, TRACING_TRACE_ID, context.traceId()); 45 | setter.set(carrier, TRACING_SPAN_ID, context.spanId()); 46 | } 47 | 48 | @Override 49 | @NotNull 50 | public Span.Builder extract(@NotNull C carrier, Getter getter) { 51 | SimpleTraceContext traceContext = new SimpleTraceContext(); 52 | 53 | String traceId = getter.get(carrier, TRACING_TRACE_ID); 54 | if (traceId != null) 55 | traceContext.setTraceId(traceId); 56 | 57 | String spanId = getter.get(carrier, TRACING_SPAN_ID); 58 | if (spanId != null) 59 | traceContext.setSpanId(spanId); 60 | 61 | Span.Builder builder = new SimpleSpanBuilder(tracer); 62 | builder.setParent(traceContext); 63 | return builder; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/java/one/tomorrow/transactionaloutbox/reactive/tracing/TracingAssertions.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.reactive.tracing; 17 | 18 | import io.micrometer.tracing.test.simple.SimpleSpan; 19 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 20 | import one.tomorrow.transactionaloutbox.reactive.model.OutboxRecord; 21 | 22 | import java.time.temporal.ChronoUnit; 23 | 24 | import static java.time.temporal.ChronoUnit.MILLIS; 25 | import static org.hamcrest.CoreMatchers.*; 26 | import static org.hamcrest.MatcherAssert.assertThat; 27 | import static org.hamcrest.Matchers.greaterThan; 28 | 29 | public interface TracingAssertions { 30 | 31 | default void assertOutboxSpan(SimpleSpan outboxSpan, String traceId, String parentId, OutboxRecord outboxRecord) { 32 | SimpleTraceContext outboxSpanContext = outboxSpan.context(); 33 | assertThat(outboxSpanContext.traceId(), is(equalTo(traceId))); 34 | assertThat(outboxSpanContext.parentId(), is(equalTo(parentId))); 35 | assertThat(outboxSpan.getStartTimestamp().truncatedTo(MILLIS), is(equalTo(outboxRecord.getCreated().truncatedTo(MILLIS)))); 36 | assertThat(outboxSpan.getEndTimestamp(), is(greaterThan(outboxRecord.getCreated()))); 37 | assertThat(outboxSpanContext.spanId(), is(notNullValue())); 38 | } 39 | 40 | default void assertProcessingSpan(SimpleTraceContext processingSpanContext, String traceId, String parentId) { 41 | assertThat(processingSpanContext.traceId(), is(equalTo(traceId))); 42 | assertThat(processingSpanContext.parentId(), is(equalTo(parentId))); 43 | assertThat(processingSpanContext.spanId(), is(notNullValue())); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/proto/sample.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "one.tomorrow.transactionaloutbox.reactive.test"; 4 | 5 | message SomethingHappened { 6 | int32 id = 1; 7 | string name = 2; 8 | } -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/resources/db/migration/V2020.06.19.22.29.00__add-outbox-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE SEQUENCE IF NOT EXISTS outbox_kafka_id_seq; 2 | 3 | CREATE TABLE IF NOT EXISTS outbox_kafka ( 4 | id BIGINT PRIMARY KEY DEFAULT nextval('outbox_kafka_id_seq'::regclass), 5 | created TIMESTAMP WITH TIME ZONE NOT NULL, 6 | processed TIMESTAMP WITH TIME ZONE NULL, 7 | topic CHARACTER VARYING(128) NOT NULL, 8 | key CHARACTER VARYING(128) NULL, 9 | value BYTEA NOT NULL, 10 | headers JSONB NULL 11 | ); 12 | 13 | CREATE INDEX idx_outbox_kafka_not_processed ON outbox_kafka (id) WHERE processed IS NULL; 14 | CREATE INDEX idx_outbox_kafka_processed ON outbox_kafka (processed); 15 | 16 | CREATE TABLE IF NOT EXISTS outbox_kafka_lock ( 17 | id CHARACTER VARYING(32) PRIMARY KEY, 18 | owner_id CHARACTER VARYING(128) NOT NULL, 19 | valid_until TIMESTAMP WITH TIME ZONE NOT NULL 20 | ); 21 | -------------------------------------------------------------------------------- /outbox-kafka-spring-reactive/src/test/resources/log4j2-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /outbox-kafka-spring/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // the version is set in parent/root build.gradle.kts 2 | 3 | dependencies { 4 | val springVersion = "6.2.6" 5 | val kafkaVersion = "3.9.0" 6 | val log4jVersion = "2.24.3" 7 | val slf4jVersion = "2.0.17" 8 | 9 | implementation("org.springframework:spring-context:$springVersion") 10 | implementation("org.springframework:spring-jdbc:$springVersion") 11 | implementation("org.postgresql:postgresql:42.7.5") 12 | implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3") 13 | implementation("org.apache.kafka:kafka-clients:$kafkaVersion") 14 | "protobufSupportImplementation"("com.google.protobuf:protobuf-java:${rootProject.extra["protobufVersion"]}") 15 | implementation("org.slf4j:slf4j-api:$slf4jVersion") 16 | implementation("jakarta.annotation:jakarta.annotation-api:3.0.0") 17 | implementation(project(":commons")) 18 | implementation(platform("io.micrometer:micrometer-tracing-bom:1.4.5")) 19 | compileOnly("io.micrometer:micrometer-tracing") 20 | 21 | // testing 22 | testImplementation(testFixtures(project(":commons"))) 23 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 24 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") 25 | testRuntimeOnly("org.junit.vintage:junit-vintage-engine") 26 | testImplementation("org.mockito:mockito-core:5.17.0") 27 | testImplementation("org.awaitility:awaitility:4.3.0") 28 | 29 | testImplementation("org.flywaydb:flyway-database-postgresql:11.8.0") 30 | testImplementation("org.flywaydb.flyway-test-extensions:flyway-spring-test:10.0.0") 31 | 32 | testImplementation("org.apache.logging.log4j:log4j-core:$log4jVersion") 33 | testImplementation("org.apache.logging.log4j:log4j-slf4j2-impl:$log4jVersion") 34 | testImplementation("org.slf4j:slf4j-simple:$slf4jVersion") 35 | testImplementation("org.apache.commons:commons-dbcp2:2.13.0") 36 | testImplementation("io.micrometer:micrometer-tracing-test") 37 | } 38 | -------------------------------------------------------------------------------- /outbox-kafka-spring/lombok.config: -------------------------------------------------------------------------------- 1 | # This file is generated by the 'io.freefair.lombok' Gradle plugin 2 | config.stopBubbling = true 3 | lombok.addLombokGeneratedAnnotation = true 4 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/model/OutboxLock.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.model; 17 | 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | import lombok.Setter; 21 | 22 | import java.time.Instant; 23 | 24 | @NoArgsConstructor 25 | @Getter 26 | @Setter 27 | public class OutboxLock { 28 | 29 | // the static value that is used to identify the single possible record in this table - i.e. we make 30 | // use of the uniqueness guarantee of the database to ensure that only a single lock at the same time exists 31 | public static final String OUTBOX_LOCK_ID = "outboxLock"; 32 | 33 | public OutboxLock(String ownerId, Instant validUntil) { 34 | this.ownerId = ownerId; 35 | this.validUntil = validUntil; 36 | } 37 | 38 | private String id = OUTBOX_LOCK_ID; 39 | 40 | private String ownerId; 41 | 42 | private Instant validUntil; 43 | 44 | } 45 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/model/OutboxRecord.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022-2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.model; 17 | 18 | import lombok.*; 19 | 20 | import java.sql.Timestamp; 21 | import java.time.Instant; 22 | import java.util.Map; 23 | 24 | @AllArgsConstructor 25 | @NoArgsConstructor 26 | @Builder 27 | @Getter 28 | @Setter 29 | @ToString(exclude = "value") 30 | @EqualsAndHashCode 31 | public class OutboxRecord { 32 | 33 | private Long id; 34 | 35 | private Timestamp created; 36 | 37 | private Instant processed; 38 | 39 | private String topic; 40 | 41 | private String key; 42 | 43 | private byte[] value; 44 | 45 | private Map headers; 46 | 47 | } 48 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/repository/OutboxRepository.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022-2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.repository; 17 | 18 | import com.fasterxml.jackson.core.JsonProcessingException; 19 | import com.fasterxml.jackson.core.type.TypeReference; 20 | import com.fasterxml.jackson.databind.ObjectMapper; 21 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 22 | import org.postgresql.util.PGobject; 23 | import org.springframework.jdbc.core.JdbcTemplate; 24 | import org.springframework.jdbc.core.RowMapper; 25 | import org.springframework.jdbc.core.simple.SimpleJdbcInsert; 26 | import org.springframework.stereotype.Repository; 27 | import org.springframework.transaction.annotation.Transactional; 28 | 29 | import java.sql.SQLException; 30 | import java.sql.Timestamp; 31 | import java.time.Instant; 32 | import java.util.HashMap; 33 | import java.util.List; 34 | import java.util.Map; 35 | 36 | @Repository 37 | public class OutboxRepository { 38 | 39 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 40 | 41 | private static final RowMapper ROW_MAPPER = (rs, rowNum) -> { 42 | Timestamp processed = rs.getTimestamp("processed"); 43 | return new OutboxRecord( 44 | rs.getLong("id"), 45 | rs.getTimestamp("created"), 46 | processed == null ? null : processed.toInstant(), 47 | rs.getString("topic"), 48 | rs.getString("key"), 49 | rs.getBytes("value"), 50 | fromJson(rs.getString("headers")) 51 | ); 52 | }; 53 | 54 | private final JdbcTemplate jdbcTemplate; 55 | 56 | private final SimpleJdbcInsert jdbcInsert; 57 | 58 | public OutboxRepository(JdbcTemplate jdbcTemplate) { 59 | this.jdbcTemplate = jdbcTemplate; 60 | this.jdbcInsert = new SimpleJdbcInsert(jdbcTemplate) 61 | .withTableName("outbox_kafka") 62 | .usingGeneratedKeyColumns("id"); 63 | } 64 | 65 | public void persist(OutboxRecord record) { 66 | record.setCreated(new Timestamp(System.currentTimeMillis())); 67 | Long id = (Long) jdbcInsert.executeAndReturnKey(argsFor(record)); 68 | record.setId(id); 69 | } 70 | 71 | private static Map argsFor(OutboxRecord record) { 72 | Map args = new HashMap<>(); 73 | args.put("created", record.getCreated()); 74 | if (record.getProcessed() != null) 75 | args.put("processed", Timestamp.from(record.getProcessed())); 76 | args.put("topic", record.getTopic()); 77 | if (record.getKey() != null) 78 | args.put("key", record.getKey()); 79 | args.put("value", record.getValue()); 80 | args.put("headers", toJson(record.getHeaders())); 81 | return args; 82 | } 83 | 84 | @Transactional 85 | public void updateProcessed(Long id, Instant processed) { 86 | jdbcTemplate.update("update outbox_kafka set processed = ? where id = ?", Timestamp.from(processed), id); 87 | } 88 | 89 | /** 90 | * Return all records that have not yet been processed (i.e. that do not have the "processed" timestamp set). 91 | * 92 | * @param limit the max number of records to return 93 | * @return the requested records, sorted by id ascending 94 | */ 95 | public List getUnprocessedRecords(int limit) { 96 | return jdbcTemplate.query("select * from outbox_kafka where processed is null order by id asc limit " + limit, ROW_MAPPER); 97 | } 98 | 99 | private static Map fromJson(String data) { 100 | try { 101 | return data == null ? null : OBJECT_MAPPER.readValue(data, new TypeReference<>() {}); 102 | } catch (JsonProcessingException e) { 103 | throw new RuntimeException(e); 104 | } 105 | } 106 | 107 | private static PGobject toJson(Map headers) { 108 | if (headers == null) 109 | return null; 110 | try { 111 | final PGobject holder = new PGobject(); 112 | holder.setType("jsonb"); 113 | holder.setValue(OBJECT_MAPPER.writeValueAsString(headers)); 114 | return holder; 115 | } catch (JsonProcessingException | SQLException e) { 116 | throw new RuntimeException(e); 117 | } 118 | } 119 | 120 | 121 | /** 122 | * Delete processed records older than defined point in time 123 | * 124 | * @param deleteOlderThan the point in time until the processed entities shall be kept 125 | * @return amount of deleted rows 126 | */ 127 | @Transactional 128 | public int deleteOutboxRecordByProcessedNotNullAndProcessedIsBefore(Instant deleteOlderThan) { 129 | return jdbcTemplate.update( 130 | "DELETE FROM outbox_kafka WHERE processed IS NOT NULL AND processed < ?", 131 | Timestamp.from(deleteOlderThan) 132 | ); 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/service/DefaultKafkaProducerFactory.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.service.OutboxProcessor.KafkaProducerFactory; 19 | import org.apache.kafka.clients.producer.KafkaProducer; 20 | import org.apache.kafka.common.serialization.ByteArraySerializer; 21 | import org.apache.kafka.common.serialization.StringSerializer; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | 25 | import java.util.HashMap; 26 | import java.util.Map; 27 | 28 | import static org.apache.kafka.clients.producer.ProducerConfig.*; 29 | import static org.apache.kafka.clients.producer.ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG; 30 | 31 | public class DefaultKafkaProducerFactory implements KafkaProducerFactory { 32 | 33 | private static final Logger logger = LoggerFactory.getLogger(DefaultKafkaProducerFactory.class); 34 | 35 | private final HashMap producerProps; 36 | 37 | public DefaultKafkaProducerFactory(Map producerProps) { 38 | HashMap props = new HashMap<>(producerProps); 39 | // Settings for guaranteed ordering (via enable.idempotence) and dealing with broker failures. 40 | // Note that with `enable.idempotence = true` ordering of messages is also checked by the broker. 41 | if (Boolean.FALSE.equals(props.get(ENABLE_IDEMPOTENCE_CONFIG))) 42 | logger.warn(ENABLE_IDEMPOTENCE_CONFIG + " is set to 'false' - this might lead to out-of-order messages."); 43 | 44 | setIfNotSet(props, ENABLE_IDEMPOTENCE_CONFIG, true); 45 | 46 | // serializer settings 47 | props.put(KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); 48 | props.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); 49 | this.producerProps = props; 50 | } 51 | 52 | private static void setIfNotSet(Map props, String prop, Object value) { 53 | if (!props.containsKey(prop)) props.put(prop, value); 54 | } 55 | 56 | @Override 57 | public KafkaProducer createKafkaProducer() { 58 | return new KafkaProducer<>(producerProps); 59 | } 60 | 61 | @Override 62 | public String toString() { 63 | return "DefaultKafkaProducerFactory{producerProps=" + loggableProducerProps(producerProps) + '}'; 64 | } 65 | 66 | static Map loggableProducerProps(Map producerProps) { 67 | Map maskedProducerProps = new HashMap<>(producerProps); 68 | maskedProducerProps.replaceAll((key, value) -> key.equalsIgnoreCase("sasl.jaas.config") ? "[hidden]" : value); 69 | return maskedProducerProps; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/service/OutboxLockService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.repository.OutboxLockRepository; 19 | import lombok.AllArgsConstructor; 20 | import lombok.Getter; 21 | import org.springframework.transaction.annotation.Transactional; 22 | 23 | import java.time.Duration; 24 | 25 | @AllArgsConstructor 26 | public class OutboxLockService { 27 | 28 | private final OutboxLockRepository repository; 29 | @Getter 30 | private final Duration lockTimeout; 31 | 32 | public boolean acquireOrRefreshLock(String ownerId) { 33 | return repository.acquireOrRefreshLock(ownerId, lockTimeout); 34 | } 35 | 36 | public void releaseLock(String ownerId) { 37 | repository.releaseLock(ownerId); 38 | } 39 | 40 | @Transactional 41 | public boolean runWithLock(String ownerId, Runnable action) { 42 | boolean outboxLockIsPreventedFromLockStealing = repository.preventLockStealing(ownerId); 43 | if (outboxLockIsPreventedFromLockStealing) { 44 | action.run(); 45 | } 46 | return outboxLockIsPreventedFromLockStealing; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/service/OutboxService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023-2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import lombok.AllArgsConstructor; 19 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 20 | import one.tomorrow.transactionaloutbox.repository.OutboxRepository; 21 | import one.tomorrow.transactionaloutbox.tracing.TracingService; 22 | import org.springframework.stereotype.Service; 23 | 24 | import java.util.HashMap; 25 | import java.util.Map; 26 | 27 | import static one.tomorrow.transactionaloutbox.commons.Maps.merge; 28 | 29 | @Service 30 | @AllArgsConstructor 31 | public class OutboxService { 32 | 33 | private OutboxRepository repository; 34 | private TracingService tracingService; 35 | 36 | public OutboxRecord saveForPublishing(String topic, String key, byte[] value) { 37 | return saveForPublishing(topic, key, value, null); 38 | } 39 | 40 | public OutboxRecord saveForPublishing(String topic, String key, byte[] value, Map headerMap) { 41 | Map tracingHeaders = tracingService.tracingHeadersForOutboxRecord(); 42 | Map headers = merge(headerMap, tracingHeaders); 43 | OutboxRecord outboxRecord = OutboxRecord.builder() 44 | .topic(topic) 45 | .key(key) 46 | .value(value) 47 | .headers(headers) 48 | .build(); 49 | repository.persist(outboxRecord); 50 | return outboxRecord; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/service/ProtobufOutboxService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import com.google.protobuf.Message; 19 | import lombok.AllArgsConstructor; 20 | import lombok.Getter; 21 | import lombok.RequiredArgsConstructor; 22 | import one.tomorrow.transactionaloutbox.commons.spring.ConditionalOnClass; 23 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 24 | import org.springframework.stereotype.Service; 25 | 26 | import java.util.Arrays; 27 | import java.util.Map; 28 | import java.util.stream.Collectors; 29 | import java.util.stream.Stream; 30 | 31 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_VALUE_TYPE_NAME; 32 | 33 | @Service 34 | @ConditionalOnClass(Message.class) 35 | @AllArgsConstructor 36 | public class ProtobufOutboxService { 37 | 38 | private OutboxService outboxService; 39 | 40 | /** 41 | * Save the message/event (as byte array), setting the {@link one.tomorrow.transactionaloutbox.commons.KafkaHeaders#HEADERS_VALUE_TYPE_NAME} 42 | * to the fully qualified name of the message descriptor. 43 | */ 44 | public OutboxRecord saveForPublishing(String topic, String key, T event, Header...headers) { 45 | byte[] value = event.toByteArray(); 46 | Header valueType = new Header(HEADERS_VALUE_TYPE_NAME, event.getDescriptorForType().getFullName()); 47 | Map headerMap = Stream.concat(Stream.of(valueType), Arrays.stream(headers)) 48 | .collect(Collectors.toMap(Header::getKey, Header::getValue)); 49 | return outboxService.saveForPublishing(topic, key, value, headerMap); 50 | } 51 | 52 | @Getter 53 | @RequiredArgsConstructor 54 | public static class Header { 55 | private final String key; 56 | private final String value; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/tracing/MicrometerTracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.TraceContext; 20 | import io.micrometer.tracing.Tracer; 21 | import io.micrometer.tracing.propagation.Propagator; 22 | import lombok.AllArgsConstructor; 23 | import one.tomorrow.transactionaloutbox.commons.spring.ConditionalOnClass; 24 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 25 | import org.springframework.context.annotation.Primary; 26 | import org.springframework.stereotype.Service; 27 | 28 | import java.util.Collections; 29 | import java.util.HashMap; 30 | import java.util.Map; 31 | import java.util.Map.Entry; 32 | import java.util.Set; 33 | import java.util.concurrent.TimeUnit; 34 | import java.util.stream.Collectors; 35 | 36 | @ConditionalOnClass(Tracer.class) 37 | @Service 38 | @Primary // if this is not good enough, NoopTracingService could use our own implementation of @ConditionalOnMissingBean 39 | @AllArgsConstructor 40 | public class MicrometerTracingService implements TracingService { 41 | 42 | static final String TO_PREFIX = "To_"; 43 | 44 | private final Tracer tracer; 45 | private final Propagator propagator; 46 | 47 | @Override 48 | public Map tracingHeadersForOutboxRecord() { 49 | TraceContext context = tracer.currentTraceContext().context(); 50 | if (context == null) { 51 | return Collections.emptyMap(); 52 | } 53 | Map result = new HashMap<>(); 54 | propagator.inject( 55 | context, 56 | result, 57 | (map, k, v) -> map.put(INTERNAL_PREFIX + k, v) 58 | ); 59 | return result; 60 | } 61 | 62 | @Override 63 | public TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord) { 64 | Set> headerEntries = outboxRecord.getHeaders().entrySet(); 65 | boolean containsTraceInfo = headerEntries.stream().anyMatch(e -> e.getKey().startsWith(INTERNAL_PREFIX)); 66 | if (!containsTraceInfo) { 67 | return new HeadersOnlyTraceOutboxRecordProcessingResult(outboxRecord.getHeaders()); 68 | } 69 | 70 | // This creates a new span with the same trace ID as the parent span 71 | Span outboxSpan = propagator.extract(outboxRecord.getHeaders(), (map, k) -> map.get(INTERNAL_PREFIX + k)) 72 | .name("transactional-outbox") 73 | .startTimestamp(outboxRecord.getCreated().getTime(), TimeUnit.MILLISECONDS) 74 | .start(); 75 | outboxSpan.end(); 76 | 77 | Map newHeaders = headerEntries.stream() 78 | .filter(entry -> !entry.getKey().startsWith(INTERNAL_PREFIX)) 79 | .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); 80 | 81 | // the span for publishing to Kafka - this span will be propagated via Kafka, and could be 82 | // referenced by consumers via "follows_from" relationship or set as parent span 83 | Span processingSpan = tracer.spanBuilder() 84 | .setParent(outboxSpan.context()) 85 | .name(TO_PREFIX + outboxRecord.getTopic()) // provides better readability in the UI 86 | .kind(Span.Kind.PRODUCER) 87 | .start(); 88 | 89 | propagator.inject(processingSpan.context(), newHeaders, Map::put); 90 | 91 | return new TraceOutboxRecordProcessingResult(newHeaders) { 92 | @Override 93 | public void publishCompleted() { 94 | processingSpan.end(); 95 | } 96 | @Override 97 | public void publishFailed(Throwable t) { 98 | processingSpan.error(t); 99 | } 100 | }; 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/tracing/NoopTracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 19 | import org.springframework.stereotype.Service; 20 | 21 | import java.util.Collections; 22 | import java.util.Map; 23 | 24 | /** 25 | * A no-op implementation of the {@link TracingService} interface. The MicrometerTracingService 26 | * should be preferred if micrometer-tracing is available on the classpath, therefore it's annotated 27 | * with {@code @Primary}. Alternatively, we could use our own implementation of {@code @ConditionalOnMissingBean} 28 | * and use this class as the default/fallback implementation. 29 | */ 30 | @Service 31 | public class NoopTracingService implements TracingService { 32 | 33 | @Override 34 | public Map tracingHeadersForOutboxRecord() { 35 | return Collections.emptyMap(); 36 | } 37 | 38 | @Override 39 | public TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord) { 40 | return new HeadersOnlyTraceOutboxRecordProcessingResult(outboxRecord.getHeaders()); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/main/java/one/tomorrow/transactionaloutbox/tracing/TracingService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import lombok.Data; 19 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 20 | 21 | import java.util.Map; 22 | 23 | public interface TracingService { 24 | 25 | String INTERNAL_PREFIX = "_internal_:"; 26 | 27 | /** 28 | * Extracts the tracing headers from the current context and returns them as a map. 29 | * If tracing is not active, an empty map is returned. 30 | *

31 | * This is meant to be used when creating an outbox record, to store the tracing headers with the record. 32 | *

33 | */ 34 | Map tracingHeadersForOutboxRecord(); 35 | 36 | /** 37 | * Extracts the tracing headers (as created via {@link #tracingHeadersForOutboxRecord()}) from the outbox record 38 | * to create a span for the time spent in the outbox.
39 | * A new span is started for the processing and publishing to Kafka, and headers to publish to Kafka are returned. 40 | * The span must be completed once the message is published to Kafka. 41 | */ 42 | TraceOutboxRecordProcessingResult traceOutboxRecordProcessing(OutboxRecord outboxRecord); 43 | 44 | @Data 45 | abstract class TraceOutboxRecordProcessingResult { 46 | 47 | private final Map headers; 48 | 49 | /** Must be invoked once the outbox record was successfully sent to Kafka */ 50 | public abstract void publishCompleted(); 51 | /** Must be invoked if the outbox record could not be sent to Kafka */ 52 | public abstract void publishFailed(Throwable t); 53 | 54 | } 55 | 56 | class HeadersOnlyTraceOutboxRecordProcessingResult extends TraceOutboxRecordProcessingResult { 57 | public HeadersOnlyTraceOutboxRecordProcessingResult(Map headers) { 58 | super(headers); 59 | } 60 | 61 | @Override 62 | public void publishCompleted() { 63 | // no-op 64 | } 65 | 66 | @Override 67 | public void publishFailed(Throwable t) { 68 | // no-op 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/IntegrationTestConfig.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox; 17 | 18 | import one.tomorrow.transactionaloutbox.commons.ProxiedPostgreSQLContainer; 19 | import org.apache.commons.dbcp2.BasicDataSource; 20 | import org.flywaydb.core.Flyway; 21 | import org.flywaydb.core.api.configuration.ClassicConfiguration; 22 | import org.flywaydb.test.FlywayHelperFactory; 23 | import org.springframework.context.annotation.Bean; 24 | import org.springframework.context.annotation.Configuration; 25 | import org.springframework.jdbc.core.JdbcTemplate; 26 | import org.springframework.jdbc.datasource.DataSourceTransactionManager; 27 | import org.springframework.transaction.annotation.EnableTransactionManagement; 28 | 29 | import javax.sql.DataSource; 30 | import java.time.Duration; 31 | import java.util.Properties; 32 | 33 | @Configuration 34 | @EnableTransactionManagement 35 | public class IntegrationTestConfig { 36 | 37 | public static final Duration DEFAULT_OUTBOX_LOCK_TIMEOUT = Duration.ofMillis(200); 38 | public static ProxiedPostgreSQLContainer postgresqlContainer = ProxiedPostgreSQLContainer.startProxiedPostgres(); 39 | 40 | @Bean 41 | public DataSource dataSource() { 42 | BasicDataSource dataSource = new BasicDataSource(); 43 | 44 | dataSource.setDriverClassName(postgresqlContainer.getDriverClassName()); 45 | dataSource.setUrl(postgresqlContainer.getJdbcUrl()); 46 | dataSource.setUsername(postgresqlContainer.getUsername()); 47 | dataSource.setPassword(postgresqlContainer.getPassword()); 48 | dataSource.setDefaultAutoCommit(false); 49 | 50 | return dataSource; 51 | } 52 | 53 | @Bean 54 | public JdbcTemplate jdbcTemplate(DataSource dataSource) { 55 | return new JdbcTemplate(dataSource); 56 | } 57 | 58 | @Bean 59 | public DataSourceTransactionManager transactionManager(DataSource dataSource) { 60 | return new DataSourceTransactionManager(dataSource); 61 | } 62 | 63 | @Bean 64 | public Flyway flywayFactory(ClassicConfiguration configuration) { 65 | FlywayHelperFactory factory = new FlywayHelperFactory(); 66 | 67 | factory.setFlywayConfiguration(configuration); 68 | factory.setFlywayProperties(new Properties()); 69 | 70 | return factory.createFlyway(); 71 | } 72 | 73 | @Bean 74 | public ClassicConfiguration flywayConfiguration(DataSource dataSource) { 75 | ClassicConfiguration configuration = new ClassicConfiguration(); 76 | 77 | configuration.setDataSource(dataSource); 78 | configuration.setLocationsAsStrings("classpath:/db/migration"); 79 | configuration.setCleanDisabled(false); 80 | 81 | return configuration; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/KafkaTestSupport.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox; 17 | 18 | import one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport; 19 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 20 | import one.tomorrow.transactionaloutbox.service.DefaultKafkaProducerFactory; 21 | import org.apache.kafka.clients.consumer.ConsumerRecord; 22 | 23 | import java.util.Map; 24 | 25 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.producerProps; 26 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_SEQUENCE_NAME; 27 | import static one.tomorrow.transactionaloutbox.commons.KafkaHeaders.HEADERS_SOURCE_NAME; 28 | import static one.tomorrow.transactionaloutbox.commons.Longs.toLong; 29 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 30 | import static org.junit.jupiter.api.Assertions.assertEquals; 31 | 32 | public interface KafkaTestSupport extends CommonKafkaTestSupport { 33 | 34 | static DefaultKafkaProducerFactory producerFactory() { 35 | return producerFactory(producerProps()); 36 | } 37 | 38 | static DefaultKafkaProducerFactory producerFactory(Map producerProps) { 39 | return new DefaultKafkaProducerFactory(producerProps); 40 | } 41 | 42 | static void assertConsumedRecord(OutboxRecord outboxRecord, String sourceHeaderValue, ConsumerRecord kafkaRecord) { 43 | assertEquals( 44 | outboxRecord.getId().longValue(), 45 | toLong(kafkaRecord.headers().lastHeader(HEADERS_SEQUENCE_NAME).value()), 46 | "OutboxRecord id and " + HEADERS_SEQUENCE_NAME + " headers do not match" 47 | ); 48 | assertArrayEquals(sourceHeaderValue.getBytes(), kafkaRecord.headers().lastHeader(HEADERS_SOURCE_NAME).value()); 49 | outboxRecord.getHeaders().forEach((key, value) -> 50 | assertArrayEquals(value.getBytes(), kafkaRecord.headers().lastHeader(key).value()) 51 | ); 52 | assertEquals(outboxRecord.getKey(), kafkaRecord.key()); 53 | assertArrayEquals(outboxRecord.getValue(), kafkaRecord.value()); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/TestUtils.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox; 17 | 18 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 19 | import org.jetbrains.annotations.NotNull; 20 | 21 | import java.time.Instant; 22 | import java.util.HashMap; 23 | import java.util.Map; 24 | import java.util.Random; 25 | 26 | public class TestUtils { 27 | 28 | private static final Random RANDOM = new Random(); 29 | 30 | public static boolean randomBoolean() { 31 | return RANDOM.nextBoolean(); 32 | } 33 | 34 | @NotNull 35 | public static Map newHeaders(String ... keyValue) { 36 | Map headers1 = new HashMap<>(); 37 | if(keyValue.length % 2 != 0) 38 | throw new IllegalArgumentException("KeyValue must be a list of pairs"); 39 | for (int i = 0; i < keyValue.length; i += 2) { 40 | headers1.put(keyValue[i], keyValue[i + 1]); 41 | } 42 | return headers1; 43 | } 44 | 45 | @NotNull 46 | public static OutboxRecord newRecord(String topic, String key, String value, Map headers) { 47 | return newRecord(null, topic, key, value, headers); 48 | } 49 | 50 | @NotNull 51 | public static OutboxRecord newRecord(Instant processed, String topic, String key, String value, Map headers) { 52 | return new OutboxRecord( 53 | null, 54 | null, 55 | processed, 56 | topic, 57 | key, 58 | value.getBytes(), 59 | headers 60 | ); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/repository/OutboxRepositoryIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.repository; 17 | 18 | import one.tomorrow.transactionaloutbox.IntegrationTestConfig; 19 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 20 | import org.flywaydb.test.FlywayTestExecutionListener; 21 | import org.flywaydb.test.annotation.FlywayTest; 22 | import org.hamcrest.CoreMatchers; 23 | import org.junit.Test; 24 | import org.junit.runner.RunWith; 25 | import org.springframework.beans.factory.annotation.Autowired; 26 | import org.springframework.jdbc.core.JdbcTemplate; 27 | import org.springframework.test.context.ContextConfiguration; 28 | import org.springframework.test.context.TestExecutionListeners; 29 | import org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener; 30 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 31 | import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; 32 | import org.springframework.test.context.support.DirtiesContextTestExecutionListener; 33 | import org.springframework.test.context.transaction.TransactionalTestExecutionListener; 34 | import org.springframework.transaction.annotation.Transactional; 35 | 36 | import java.time.Duration; 37 | import java.time.Instant; 38 | import java.util.Collections; 39 | import java.util.List; 40 | 41 | import static one.tomorrow.transactionaloutbox.TestUtils.newHeaders; 42 | import static one.tomorrow.transactionaloutbox.TestUtils.newRecord; 43 | import static org.hamcrest.MatcherAssert.assertThat; 44 | import static org.junit.Assert.assertEquals; 45 | import static org.junit.Assert.assertFalse; 46 | import static org.junit.Assert.assertTrue; 47 | 48 | @RunWith(SpringJUnit4ClassRunner.class) 49 | @ContextConfiguration(classes = { 50 | OutboxRepository.class, 51 | OutboxRecord.class, 52 | IntegrationTestConfig.class}) 53 | @TestExecutionListeners({ 54 | DependencyInjectionTestExecutionListener.class, 55 | TransactionalTestExecutionListener.class, 56 | SqlScriptsTestExecutionListener.class, 57 | DirtiesContextTestExecutionListener.class, 58 | FlywayTestExecutionListener.class}) 59 | @FlywayTest 60 | @Transactional 61 | public class OutboxRepositoryIntegrationTest { 62 | 63 | @Autowired 64 | private OutboxRepository testee; 65 | 66 | @Autowired 67 | private JdbcTemplate jdbcTemplate; 68 | 69 | @Test 70 | public void should_FindUnprocessedRecords() { 71 | // given 72 | OutboxRecord record1 = newRecord(Instant.now(), "topic1", "key1", "value1", newHeaders("h1", "v1")); 73 | testee.persist(record1); 74 | 75 | OutboxRecord record2 = newRecord("topic2", "key2", "value2", newHeaders("h2", "v2")); 76 | testee.persist(record2); 77 | 78 | // when 79 | List result = testee.getUnprocessedRecords(100); 80 | 81 | // then 82 | assertThat(result.size(), CoreMatchers.is(1)); 83 | OutboxRecord foundRecord = result.get(0); 84 | assertEquals(record2, foundRecord); 85 | } 86 | 87 | @Test 88 | public void should_DeleteProcessedRecordsAfterRetentionTime() { 89 | // given 90 | OutboxRecord shouldBeKeptAsNotProcessed = newRecord(null, "topic1", "key1", "value1", Collections.emptyMap()); 91 | testee.persist(shouldBeKeptAsNotProcessed); 92 | 93 | OutboxRecord shouldBeKeptAsNotInDeletionPeriod = newRecord(Instant.now().minus(Duration.ofDays(1)), "topic1", "key1", "value3", Collections.emptyMap()); 94 | testee.persist(shouldBeKeptAsNotInDeletionPeriod); 95 | 96 | OutboxRecord shouldBeDeleted1 = newRecord(Instant.now().minus(Duration.ofDays(16)), "topic1", "key1", "value1", Collections.emptyMap()); 97 | testee.persist(shouldBeDeleted1); 98 | 99 | OutboxRecord shouldBeDeleted2 = newRecord(Instant.now().minus(Duration.ofDays(18)), "topic1", "key1", "value2", Collections.emptyMap()); 100 | testee.persist(shouldBeDeleted2); 101 | OutboxRecord shouldBeDeleted3 = newRecord(Instant.now().minus(Duration.ofDays(150)), "topic1", "key1", "value2", Collections.emptyMap()); 102 | testee.persist(shouldBeDeleted3); 103 | 104 | // when 105 | Integer result = testee.deleteOutboxRecordByProcessedNotNullAndProcessedIsBefore(Instant.now().minus(Duration.ofDays(15))); 106 | 107 | // then 108 | assertThat(result, CoreMatchers.is(3)); 109 | assertFalse(outboxRecordExists(shouldBeDeleted1.getId())); 110 | assertFalse(outboxRecordExists(shouldBeDeleted2.getId())); 111 | assertFalse(outboxRecordExists(shouldBeDeleted3.getId())); 112 | assertTrue(outboxRecordExists(shouldBeKeptAsNotInDeletionPeriod.getId())); 113 | assertTrue(outboxRecordExists(shouldBeKeptAsNotProcessed.getId())); 114 | } 115 | 116 | private boolean outboxRecordExists(Long id) { 117 | Long result = jdbcTemplate.query( 118 | "select count(*) from outbox_kafka where id = ?", 119 | rs -> rs.next() ? rs.getLong(1) : null, 120 | id 121 | ); 122 | return result != null && result > 0; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/ConcurrentOutboxProcessorsIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.IntegrationTestConfig; 19 | import one.tomorrow.transactionaloutbox.KafkaTestSupport; 20 | import one.tomorrow.transactionaloutbox.commons.ProxiedKafkaContainer; 21 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 22 | import one.tomorrow.transactionaloutbox.repository.OutboxLockRepository; 23 | import one.tomorrow.transactionaloutbox.repository.OutboxRepository; 24 | import org.apache.kafka.clients.consumer.Consumer; 25 | import org.apache.kafka.clients.consumer.ConsumerRecord; 26 | import org.flywaydb.test.FlywayTestExecutionListener; 27 | import org.flywaydb.test.annotation.FlywayTest; 28 | import org.junit.After; 29 | import org.junit.AfterClass; 30 | import org.junit.Test; 31 | import org.junit.jupiter.api.BeforeAll; 32 | import org.junit.runner.RunWith; 33 | import org.springframework.beans.factory.annotation.Autowired; 34 | import org.springframework.beans.factory.config.AutowireCapableBeanFactory; 35 | import org.springframework.test.context.ContextConfiguration; 36 | import org.springframework.test.context.TestExecutionListeners; 37 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 38 | import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; 39 | 40 | import java.time.Duration; 41 | import java.util.Iterator; 42 | import java.util.List; 43 | 44 | import static java.util.stream.IntStream.range; 45 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.*; 46 | import static one.tomorrow.transactionaloutbox.KafkaTestSupport.*; 47 | import static one.tomorrow.transactionaloutbox.commons.ProxiedKafkaContainer.bootstrapServers; 48 | import static one.tomorrow.transactionaloutbox.TestUtils.newHeaders; 49 | import static one.tomorrow.transactionaloutbox.TestUtils.newRecord; 50 | 51 | @RunWith(SpringJUnit4ClassRunner.class) 52 | @ContextConfiguration(classes = { 53 | OutboxRecord.class, 54 | OutboxRepository.class, 55 | OutboxLockRepository.class, 56 | TransactionalOutboxRepository.class, 57 | IntegrationTestConfig.class 58 | }) 59 | @TestExecutionListeners({ 60 | DependencyInjectionTestExecutionListener.class, 61 | FlywayTestExecutionListener.class 62 | }) 63 | @FlywayTest 64 | @SuppressWarnings("unused") 65 | public class ConcurrentOutboxProcessorsIntegrationTest implements KafkaTestSupport { 66 | 67 | public static final ProxiedKafkaContainer kafkaContainer = ProxiedKafkaContainer.startProxiedKafka(); 68 | private static final String topic = "topicConcurrentTest"; 69 | private static Consumer consumer; 70 | 71 | @Autowired 72 | private OutboxRepository repository; 73 | @Autowired 74 | private TransactionalOutboxRepository transactionalRepository; 75 | @Autowired 76 | private OutboxLockRepository lockRepository; 77 | @Autowired 78 | private AutowireCapableBeanFactory beanFactory; 79 | 80 | private OutboxProcessor testee1; 81 | private OutboxProcessor testee2; 82 | 83 | @BeforeAll 84 | public static void beforeAll() { 85 | createTopic(bootstrapServers, topic); 86 | } 87 | 88 | @AfterClass 89 | public static void afterClass() { 90 | if (consumer != null) 91 | consumer.close(); 92 | } 93 | 94 | @After 95 | public void afterTest() { 96 | testee1.close(); 97 | testee2.close(); 98 | } 99 | 100 | @Test 101 | public void should_ProcessRecordsOnceInOrder() { 102 | // given 103 | Duration lockTimeout = Duration.ofMillis(20); // very aggressive lock stealing 104 | Duration processingInterval = Duration.ZERO; 105 | String eventSource = "test"; 106 | testee1 = new OutboxProcessor(repository, producerFactory(), processingInterval, lockTimeout, "processor1", eventSource, beanFactory); 107 | testee2 = new OutboxProcessor(repository, producerFactory(), processingInterval, lockTimeout, "processor2", eventSource, beanFactory); 108 | 109 | // when 110 | List outboxRecords = range(0, 1000).mapToObj( 111 | i -> newRecord(topic, "key1", "value" + i, newHeaders("h", "v" + i)) 112 | ).toList(); 113 | outboxRecords.forEach(transactionalRepository::persist); 114 | 115 | // then 116 | Iterator> kafkaRecords = getAndCommitRecords(outboxRecords.size()).iterator(); 117 | outboxRecords.forEach(outboxRecord -> 118 | assertConsumedRecord(outboxRecord, eventSource, kafkaRecords.next()) 119 | ); 120 | } 121 | 122 | @Override 123 | public Consumer consumer() { 124 | if (consumer == null) { 125 | consumer = createConsumer(bootstrapServers); 126 | consumer.subscribe(List.of(topic)); 127 | } 128 | return consumer; 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/DefaultKafkaProducerFactoryTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Tomorrow GmbH @ https://tomorrow.one 3 | *

4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | *

8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | *

10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import org.junit.Test; 19 | 20 | import java.util.Map; 21 | 22 | import static one.tomorrow.transactionaloutbox.commons.CommonKafkaTestSupport.producerProps; 23 | import static org.apache.kafka.clients.producer.ProducerConfig.BOOTSTRAP_SERVERS_CONFIG; 24 | import static org.apache.kafka.common.config.SaslConfigs.SASL_JAAS_CONFIG; 25 | import static org.junit.Assert.assertEquals; 26 | 27 | @SuppressWarnings("unchecked") 28 | public class DefaultKafkaProducerFactoryTest { 29 | 30 | @Test 31 | public void should_buildLoggableProducerWithoutSensitiveContent() { 32 | // given 33 | Map producerProps = producerProps("bootstrapServers"); 34 | String saslJaasConfig = "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"abc-backend-user\" password=\"xyz\";"; 35 | producerProps.put(SASL_JAAS_CONFIG, saslJaasConfig); 36 | 37 | // when 38 | Map loggableProducerProps = DefaultKafkaProducerFactory.loggableProducerProps(producerProps); 39 | 40 | // then 41 | assertEquals("[hidden]", loggableProducerProps.get(SASL_JAAS_CONFIG)); 42 | assertEquals("bootstrapServers", loggableProducerProps.get(BOOTSTRAP_SERVERS_CONFIG)); 43 | 44 | // make sure we don't change the original values by side effect 45 | assertEquals(saslJaasConfig, producerProps.get(SASL_JAAS_CONFIG)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/OutboxLockServiceIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.IntegrationTestConfig; 19 | import one.tomorrow.transactionaloutbox.model.OutboxLock; 20 | import one.tomorrow.transactionaloutbox.repository.OutboxLockRepository; 21 | import org.flywaydb.test.FlywayTestExecutionListener; 22 | import org.flywaydb.test.annotation.FlywayTest; 23 | import org.junit.AfterClass; 24 | import org.junit.BeforeClass; 25 | import org.junit.Test; 26 | import org.junit.runner.RunWith; 27 | import org.slf4j.Logger; 28 | import org.slf4j.LoggerFactory; 29 | import org.springframework.beans.factory.annotation.Autowired; 30 | import org.springframework.context.ApplicationContext; 31 | import org.springframework.test.context.ContextConfiguration; 32 | import org.springframework.test.context.TestExecutionListeners; 33 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 34 | import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; 35 | 36 | import java.time.Duration; 37 | import java.util.concurrent.*; 38 | 39 | import static java.util.concurrent.TimeUnit.SECONDS; 40 | import static org.junit.Assert.assertFalse; 41 | import static org.junit.Assert.assertTrue; 42 | import static org.junit.Assume.assumeTrue; 43 | 44 | @RunWith(SpringJUnit4ClassRunner.class) 45 | @ContextConfiguration(classes = { 46 | OutboxLock.class, 47 | OutboxLockRepository.class, 48 | IntegrationTestConfig.class 49 | }) 50 | @TestExecutionListeners({ 51 | DependencyInjectionTestExecutionListener.class, 52 | FlywayTestExecutionListener.class 53 | }) 54 | @FlywayTest 55 | @SuppressWarnings("unused") 56 | public class OutboxLockServiceIntegrationTest { 57 | 58 | private static final Logger logger = LoggerFactory.getLogger(OutboxLockServiceIntegrationTest.class); 59 | 60 | @Autowired 61 | private OutboxLockRepository lockRepository; 62 | @Autowired 63 | private ApplicationContext applicationContext; 64 | 65 | private static ExecutorService executorService; 66 | 67 | @BeforeClass 68 | public static void beforeClass() { 69 | executorService = Executors.newCachedThreadPool(); 70 | } 71 | 72 | @AfterClass 73 | public static void afterClass() { 74 | executorService.shutdown(); 75 | } 76 | 77 | @Test 78 | public void should_RunWithLock_PreventLockStealing() throws ExecutionException, InterruptedException, TimeoutException { 79 | // given 80 | String ownerId1 = "owner-1"; 81 | String ownerId2 = "owner-2"; 82 | OutboxLockService lockService = postProcessBeanForTransactionCapabilities(new OutboxLockService(lockRepository, Duration.ZERO)); 83 | 84 | boolean locked = lockService.acquireOrRefreshLock(ownerId1); 85 | assumeTrue(locked); 86 | 87 | CyclicBarrier barrier1 = new CyclicBarrier(2); 88 | CyclicBarrier barrier2 = new CyclicBarrier(2); 89 | CyclicBarrier barrier3 = new CyclicBarrier(2); 90 | 91 | // when 92 | Future runWithLockResult = executorService.submit(() -> { 93 | await(barrier1); 94 | return lockService.runWithLock(ownerId1, () -> { 95 | await(barrier2); 96 | await(barrier3); // exit runWithLock not before owner2 has tried to "acquireOrRefreshLock" 97 | }); 98 | }); 99 | Future lockStealingAttemptResult = executorService.submit(() -> { 100 | await(barrier1); 101 | await(barrier2); // start acquireOrRefreshLock not before owner1 is inside "runWithLock" 102 | boolean result = lockService.acquireOrRefreshLock(ownerId2); 103 | await(barrier3); 104 | return result; 105 | }); 106 | 107 | // then 108 | assertTrue(runWithLockResult.get(5, SECONDS)); 109 | assertFalse(lockStealingAttemptResult.get(5, SECONDS)); 110 | } 111 | 112 | /** Awaits the given barrier, turning checked exceptions into unchecked, for easier usage in lambdas. */ 113 | private void await(CyclicBarrier barrier) { 114 | try { 115 | barrier.await(); 116 | } catch (Exception e) { 117 | throw new RuntimeException(e); 118 | } 119 | } 120 | 121 | @SuppressWarnings("unchecked") 122 | private T postProcessBeanForTransactionCapabilities(T bean) { 123 | return (T) applicationContext.getAutowireCapableBeanFactory().initializeBean(bean, null); 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/OutboxProcessorTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 19 | import one.tomorrow.transactionaloutbox.repository.OutboxRepository; 20 | import one.tomorrow.transactionaloutbox.service.OutboxProcessor.KafkaProducerFactory; 21 | import org.apache.kafka.clients.producer.Callback; 22 | import org.apache.kafka.clients.producer.KafkaProducer; 23 | import org.apache.kafka.clients.producer.ProducerRecord; 24 | import org.apache.kafka.clients.producer.RecordMetadata; 25 | import org.apache.kafka.common.TopicPartition; 26 | import org.jetbrains.annotations.NotNull; 27 | import org.junit.Before; 28 | import org.junit.Test; 29 | import org.mockito.ArgumentMatcher; 30 | import org.springframework.beans.factory.config.AutowireCapableBeanFactory; 31 | 32 | import java.time.Duration; 33 | import java.util.List; 34 | import java.util.Objects; 35 | import java.util.concurrent.ExecutionException; 36 | import java.util.concurrent.Future; 37 | import java.util.concurrent.atomic.AtomicInteger; 38 | 39 | import static java.lang.Thread.sleep; 40 | import static org.junit.Assert.assertEquals; 41 | import static org.mockito.Mockito.*; 42 | 43 | @SuppressWarnings("unchecked") 44 | public class OutboxProcessorTest { 45 | 46 | private final OutboxRepository repository = mock(OutboxRepository.class); 47 | 48 | private final KafkaProducerFactory producerFactory = mock(KafkaProducerFactory.class); 49 | 50 | private final AutowireCapableBeanFactory beanFactory = mock(AutowireCapableBeanFactory.class); 51 | 52 | private final KafkaProducer producer = mock(KafkaProducer.class); 53 | 54 | private final OutboxRecord record1 = mock(OutboxRecord.class, RETURNS_MOCKS); 55 | private final OutboxRecord record2 = mock(OutboxRecord.class, RETURNS_MOCKS); 56 | private final List records = List.of(record1, record2); 57 | 58 | private final Future future1 = mock(Future.class); 59 | private final Future future2 = mock(Future.class); 60 | 61 | private OutboxProcessor processor; 62 | 63 | @Before 64 | public void setup() { 65 | when(producerFactory.createKafkaProducer()).thenReturn(producer); 66 | 67 | OutboxLockService lockService = mock(OutboxLockService.class); 68 | when(lockService.getLockTimeout()).thenReturn(Duration.ZERO); 69 | when(beanFactory.initializeBean(any(), anyString())).thenReturn(lockService); 70 | 71 | processor = new OutboxProcessor( 72 | repository, 73 | producerFactory, 74 | Duration.ZERO, 75 | Duration.ZERO, 76 | "lockOwnerId", 77 | "eventSource", 78 | beanFactory); 79 | 80 | when(record1.getId()).thenReturn(1L); 81 | when(record1.getKey()).thenReturn("r1"); 82 | when(record2.getId()).thenReturn(2L); 83 | when(record2.getKey()).thenReturn("r2"); 84 | } 85 | 86 | /* Verifies, that all items are submitted to producer.send before the first future.get() is invoked */ 87 | @Test 88 | public void processOutboxShouldUseProducerInternalBatching() throws ExecutionException, InterruptedException { 89 | when(repository.getUnprocessedRecords(anyInt())).thenReturn(records); 90 | 91 | AtomicInteger sendCounter = new AtomicInteger(0); 92 | 93 | when(producer.send(argThat(matching(record1)), any())).thenAnswer(invocation -> { 94 | sendCounter.incrementAndGet(); 95 | sleep(10); 96 | return future1; 97 | }); 98 | when(producer.send(argThat(matching(record2)), any())).thenAnswer(invocation -> { 99 | sendCounter.incrementAndGet(); 100 | sleep(10); 101 | return future2; 102 | }); 103 | 104 | when(future1.get()).thenAnswer(invocation -> { 105 | assertEquals(2, sendCounter.get()); 106 | return null; 107 | }); 108 | 109 | when(future2.get()).thenAnswer(invocation -> { 110 | assertEquals(2, sendCounter.get()); 111 | return null; 112 | }); 113 | 114 | processor.processOutbox(); 115 | } 116 | 117 | @Test 118 | public void processOutboxShouldSetProcessedOnlyOnSuccess() { 119 | when(repository.getUnprocessedRecords(anyInt())).thenReturn(records); 120 | 121 | RecordMetadata metadata = new RecordMetadata(new TopicPartition("t", -1), -1, -1, -1, -1, -1); 122 | 123 | when(producer.send(argThat(matching(record1)), any())).thenAnswer(invocation -> { 124 | Callback callback = (Callback) invocation.getArguments()[1]; 125 | callback.onCompletion(metadata, new RuntimeException("simulated exception")); 126 | return future1; 127 | }); 128 | when(producer.send(argThat(matching(record2)), any())).thenAnswer(invocation -> { 129 | Callback callback = (Callback) invocation.getArguments()[1]; 130 | callback.onCompletion(metadata, null); 131 | return future2; 132 | }); 133 | 134 | processor.processOutbox(); 135 | 136 | verify(repository, never()).updateProcessed(eq(record1.getId()), any()); 137 | verify(repository).updateProcessed(eq(record2.getId()), any()); 138 | } 139 | 140 | @NotNull 141 | private static ArgumentMatcher> matching(OutboxRecord record) { 142 | return item -> { 143 | if (item == null) 144 | return false; 145 | return Objects.equals(item.key(), record.getKey()); 146 | }; 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/SampleProtobufService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import lombok.AllArgsConstructor; 19 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 20 | import one.tomorrow.transactionaloutbox.service.ProtobufOutboxService.Header; 21 | import one.tomorrow.transactionaloutbox.test.Sample.SomethingHappened; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | import org.springframework.stereotype.Service; 25 | import org.springframework.transaction.annotation.Transactional; 26 | 27 | import static one.tomorrow.transactionaloutbox.service.SampleProtobufService.Topics.topic1; 28 | 29 | @Service 30 | @AllArgsConstructor 31 | public class SampleProtobufService { 32 | 33 | private static final Logger logger = LoggerFactory.getLogger(SampleProtobufService.class); 34 | 35 | private ProtobufOutboxService outboxService; 36 | 37 | @Transactional 38 | public void doSomething(int id, String name) { 39 | // Here s.th. else would be done within the transaction, e.g. some entity created. 40 | // We record this fact with the event that shall be published to interested parties / consumers. 41 | SomethingHappened event = SomethingHappened.newBuilder() 42 | .setId(id) 43 | .setName(name) 44 | .build(); 45 | OutboxRecord record = outboxService.saveForPublishing(topic1, String.valueOf(id), event); 46 | logger.info("Stored event [{}] in outbox with id {}, key {} and headers {}", event, record.getId(), record.getKey(), record.getHeaders()); 47 | } 48 | 49 | @Transactional 50 | public void doSomethingWithAdditionalHeaders(int id, String name, Header...headers) { 51 | // Here s.th. else would be done within the transaction, e.g. some entity created. 52 | // We record this fact with the event that shall be published to interested parties / consumers. 53 | SomethingHappened event = SomethingHappened.newBuilder() 54 | .setId(id) 55 | .setName(name) 56 | .build(); 57 | OutboxRecord record = outboxService.saveForPublishing(topic1, String.valueOf(id), event, headers); 58 | logger.info("Stored event [{}] in outbox with id {}, key {} and headers {}", event, record.getId(), record.getKey(), record.getHeaders()); 59 | } 60 | 61 | abstract static class Topics { 62 | public static final String topic1 = "sampleProtobufTopic"; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/SampleService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import lombok.AllArgsConstructor; 19 | import lombok.Getter; 20 | import lombok.RequiredArgsConstructor; 21 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | import org.springframework.stereotype.Service; 25 | import org.springframework.transaction.annotation.Transactional; 26 | 27 | import java.util.Arrays; 28 | import java.util.Map; 29 | import java.util.stream.Collectors; 30 | 31 | import static one.tomorrow.transactionaloutbox.service.SampleService.Topics.topic1; 32 | 33 | @Service 34 | @AllArgsConstructor 35 | public class SampleService { 36 | 37 | private static final Logger logger = LoggerFactory.getLogger(SampleService.class); 38 | 39 | private OutboxService outboxService; 40 | 41 | @Transactional 42 | public void doSomething(int id, String something) { 43 | // Here s.th. else would be done within the transaction, e.g. some entity created. 44 | // We record this fact with the event that shall be published to interested parties / consumers. 45 | OutboxRecord outboxRecord = outboxService.saveForPublishing(topic1, String.valueOf(id), something.getBytes()); 46 | logger.info("Stored event [{}] in outbox with id {} and key {}", something, outboxRecord.getId(), outboxRecord.getKey()); 47 | } 48 | 49 | @Transactional 50 | public OutboxRecord doSomethingWithAdditionalHeaders(int id, String something, Header...headers) { 51 | // Here s.th. else would be done within the transaction, e.g. some entity created. 52 | // We record this fact with the event that shall be published to interested parties / consumers. 53 | Map headerMap = Arrays.stream(headers) 54 | .collect(Collectors.toMap(Header::getKey, Header::getValue)); 55 | OutboxRecord outboxRecord = outboxService.saveForPublishing(topic1, String.valueOf(id), something.getBytes(), headerMap); 56 | logger.info("Stored event [{}] in outbox with id {}, key {} and headers {}", something, outboxRecord.getId(), outboxRecord.getKey(), outboxRecord.getHeaders()); 57 | return outboxRecord; 58 | } 59 | 60 | @Getter 61 | @RequiredArgsConstructor 62 | public static class Header { 63 | private final String key; 64 | private final String value; 65 | } 66 | 67 | abstract static class Topics { 68 | public static final String topic1 = "sampleTopic"; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/service/TransactionalOutboxRepository.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.service; 17 | 18 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 19 | import one.tomorrow.transactionaloutbox.repository.OutboxRepository; 20 | import org.springframework.stereotype.Repository; 21 | import org.springframework.transaction.annotation.Transactional; 22 | 23 | /** 24 | * Helper class, which provides the transactional boundary for ${@link OutboxRepository#persist(OutboxRecord)} 25 | */ 26 | @Repository 27 | public class TransactionalOutboxRepository { 28 | 29 | private OutboxRepository repository; 30 | 31 | public TransactionalOutboxRepository(OutboxRepository repository) { 32 | this.repository = repository; 33 | } 34 | 35 | @Transactional 36 | public void persist(OutboxRecord record) { 37 | repository.persist(record); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/tracing/MicrometerTracingIntegrationTestConfig.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import io.micrometer.tracing.propagation.Propagator; 19 | import io.micrometer.tracing.test.simple.SimpleTracer; 20 | import org.springframework.context.annotation.Bean; 21 | import org.springframework.context.annotation.Configuration; 22 | 23 | @Configuration 24 | public class MicrometerTracingIntegrationTestConfig { 25 | 26 | @Bean 27 | public SimpleTracer simpleTracer() { 28 | return new SimpleTracer(); 29 | } 30 | 31 | @Bean 32 | public Propagator propagator(SimpleTracer tracer) { 33 | return new SimplePropagator(tracer); 34 | } 35 | 36 | @Bean 37 | public TracingService tracingService(SimpleTracer tracer, Propagator propagator) { 38 | return new MicrometerTracingService(tracer, propagator); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/tracing/MicrometerTracingServiceTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.Tracer.SpanInScope; 20 | import io.micrometer.tracing.test.simple.SimpleSpan; 21 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 22 | import io.micrometer.tracing.test.simple.SimpleTracer; 23 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 24 | import one.tomorrow.transactionaloutbox.tracing.TracingService.TraceOutboxRecordProcessingResult; 25 | import org.junit.jupiter.api.BeforeEach; 26 | import org.junit.jupiter.api.Test; 27 | 28 | import java.sql.Timestamp; 29 | import java.time.Instant; 30 | import java.util.Map; 31 | 32 | import static java.lang.System.currentTimeMillis; 33 | import static java.time.temporal.ChronoUnit.MILLIS; 34 | import static one.tomorrow.transactionaloutbox.tracing.SimplePropagator.TRACING_SPAN_ID; 35 | import static one.tomorrow.transactionaloutbox.tracing.SimplePropagator.TRACING_TRACE_ID; 36 | import static org.hamcrest.MatcherAssert.assertThat; 37 | import static org.hamcrest.Matchers.*; 38 | import static org.junit.jupiter.api.Assertions.*; 39 | 40 | class MicrometerTracingServiceTest implements TracingAssertions { 41 | 42 | private SimpleTracer tracer; 43 | private MicrometerTracingService micrometerTracingService; 44 | 45 | @BeforeEach 46 | void setUp() { 47 | tracer = new SimpleTracer(); 48 | micrometerTracingService = new MicrometerTracingService(tracer, new SimplePropagator(tracer)); 49 | } 50 | 51 | @Test 52 | void tracingHeadersForOutboxRecord_withoutActiveTraceContext_returnsEmptyMap() { 53 | Map headers = micrometerTracingService.tracingHeadersForOutboxRecord(); 54 | assertTrue(headers.isEmpty()); 55 | } 56 | 57 | @Test 58 | void tracingHeadersForOutboxRecord_withActiveTraceContext_returnsHeaders() { 59 | Span span = tracer.nextSpan().name("test-span").start(); 60 | try (SpanInScope ignored = tracer.withSpan(span)) { 61 | Map headers = micrometerTracingService.tracingHeadersForOutboxRecord(); 62 | assertFalse(headers.isEmpty()); 63 | assertEquals(span.context().traceId(), headers.get("_internal_:" + TRACING_TRACE_ID)); 64 | assertEquals(span.context().spanId(), headers.get("_internal_:" + TRACING_SPAN_ID)); 65 | } finally { 66 | span.end(); 67 | } 68 | } 69 | 70 | @Test 71 | void traceOutboxRecordProcessing_withValidOutboxRecord_createsAndEndsSpan() { 72 | OutboxRecord outboxRecord = new OutboxRecord(); 73 | String traceId = "traceId1"; 74 | String spanId = "spanId1"; 75 | outboxRecord.setHeaders(Map.of( 76 | "some", "header", 77 | "_internal_:" + TRACING_TRACE_ID, traceId, 78 | "_internal_:" + TRACING_SPAN_ID, spanId)); 79 | outboxRecord.setCreated(new Timestamp(currentTimeMillis() - 42)); 80 | 81 | TraceOutboxRecordProcessingResult result = micrometerTracingService.traceOutboxRecordProcessing(outboxRecord); 82 | 83 | // verify recorded span for the outbox record in the transactional-outbox 84 | assertEquals(2, tracer.getSpans().size()); // one for the transactional-outbox and one for the processing to Kafka 85 | SimpleSpan outboxSpan = tracer.getSpans().getFirst(); 86 | assertOutboxSpan(outboxSpan, traceId, spanId, outboxRecord); 87 | 88 | // verify recorded span for the processing to Kafka 89 | SimpleSpan processingSpan = tracer.getSpans().getLast(); 90 | SimpleTraceContext processingSpanContext = processingSpan.context(); 91 | assertProcessingSpan(processingSpanContext, traceId, outboxSpan.context().spanId()); 92 | 93 | // verify returned headers 94 | Map headers = result.getHeaders(); 95 | assertThat(headers, hasEntry("some", "header")); 96 | assertThat(headers, hasEntry(TRACING_TRACE_ID, traceId)); 97 | assertThat(headers, hasEntry(TRACING_SPAN_ID, processingSpanContext.spanId())); 98 | 99 | // verify that the processing span is ended correctly 100 | // initially the end timespan is 101 | assertEquals(Instant.ofEpochMilli(0L), processingSpan.getEndTimestamp()); 102 | Instant before = Instant.now().truncatedTo(MILLIS); 103 | result.publishCompleted(); 104 | Instant after = Instant.now().truncatedTo(MILLIS); 105 | assertThat(before, lessThanOrEqualTo(processingSpan.getEndTimestamp())); 106 | assertThat(after, greaterThanOrEqualTo(processingSpan.getEndTimestamp())); 107 | } 108 | 109 | @Test 110 | void traceOutboxRecordProcessing_withoutTraceHeaders_ignoresTracing() { 111 | OutboxRecord outboxRecord = new OutboxRecord(); 112 | outboxRecord.setHeaders(Map.of("some", "header")); 113 | outboxRecord.setCreated(new Timestamp(currentTimeMillis())); 114 | 115 | TraceOutboxRecordProcessingResult result = micrometerTracingService.traceOutboxRecordProcessing(outboxRecord); 116 | 117 | assertTrue(tracer.getSpans().isEmpty()); 118 | Map headers = result.getHeaders(); 119 | assertEquals(1, headers.size()); 120 | assertEquals("header", headers.get("some")); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/tracing/SimplePropagator.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import io.micrometer.tracing.Span; 19 | import io.micrometer.tracing.TraceContext; 20 | import io.micrometer.tracing.propagation.Propagator; 21 | import io.micrometer.tracing.test.simple.SimpleSpanBuilder; 22 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 23 | import io.micrometer.tracing.test.simple.SimpleTracer; 24 | import lombok.RequiredArgsConstructor; 25 | import org.jetbrains.annotations.NotNull; 26 | 27 | import java.util.List; 28 | 29 | @RequiredArgsConstructor 30 | public class SimplePropagator implements Propagator { 31 | 32 | public static final String TRACING_TRACE_ID = "traceId"; 33 | public static final String TRACING_SPAN_ID = "spanId"; 34 | private final SimpleTracer tracer; 35 | 36 | @Override 37 | @NotNull 38 | public List fields() { 39 | return List.of(TRACING_TRACE_ID, TRACING_SPAN_ID); 40 | } 41 | 42 | @Override 43 | public void inject(TraceContext context, C carrier, Setter setter) { 44 | setter.set(carrier, TRACING_TRACE_ID, context.traceId()); 45 | setter.set(carrier, TRACING_SPAN_ID, context.spanId()); 46 | } 47 | 48 | @Override 49 | @NotNull 50 | public Span.Builder extract(@NotNull C carrier, Getter getter) { 51 | SimpleTraceContext traceContext = new SimpleTraceContext(); 52 | 53 | String traceId = getter.get(carrier, TRACING_TRACE_ID); 54 | if (traceId != null) 55 | traceContext.setTraceId(traceId); 56 | 57 | String spanId = getter.get(carrier, TRACING_SPAN_ID); 58 | if (spanId != null) 59 | traceContext.setSpanId(spanId); 60 | 61 | Span.Builder builder = new SimpleSpanBuilder(tracer); 62 | builder.setParent(traceContext); 63 | return builder; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/java/one/tomorrow/transactionaloutbox/tracing/TracingAssertions.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Tomorrow GmbH @ https://tomorrow.one 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package one.tomorrow.transactionaloutbox.tracing; 17 | 18 | import io.micrometer.tracing.test.simple.SimpleSpan; 19 | import io.micrometer.tracing.test.simple.SimpleTraceContext; 20 | import one.tomorrow.transactionaloutbox.model.OutboxRecord; 21 | 22 | import static org.junit.jupiter.api.Assertions.*; 23 | 24 | public interface TracingAssertions { 25 | 26 | default void assertOutboxSpan(SimpleSpan outboxSpan, String traceId, String parentId, OutboxRecord outboxRecord) { 27 | SimpleTraceContext outboxSpanContext = outboxSpan.context(); 28 | assertEquals(traceId, outboxSpanContext.traceId()); 29 | assertEquals(parentId, outboxSpanContext.parentId()); 30 | assertEquals(outboxRecord.getCreated().toInstant(), outboxSpan.getStartTimestamp()); 31 | assertTrue(outboxSpan.getEndTimestamp().isAfter(outboxRecord.getCreated().toInstant())); 32 | assertNotNull(outboxSpanContext.spanId()); 33 | } 34 | 35 | default void assertProcessingSpan(SimpleTraceContext processingSpanContext, String traceId, String parentId) { 36 | assertEquals(traceId, processingSpanContext.traceId()); 37 | assertEquals(parentId, processingSpanContext.parentId()); 38 | assertNotNull(processingSpanContext.spanId()); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/proto/sample.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "one.tomorrow.transactionaloutbox.test"; 4 | 5 | message SomethingHappened { 6 | int32 id = 1; 7 | string name = 2; 8 | } -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/resources/db/migration/V2020.06.19.22.29.00__add-outbox-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE SEQUENCE IF NOT EXISTS outbox_kafka_id_seq; 2 | 3 | CREATE TABLE IF NOT EXISTS outbox_kafka ( 4 | id BIGINT PRIMARY KEY DEFAULT nextval('outbox_kafka_id_seq'::regclass), 5 | created TIMESTAMP WITHOUT TIME ZONE NOT NULL, 6 | processed TIMESTAMP WITHOUT TIME ZONE NULL, 7 | topic CHARACTER VARYING(128) NOT NULL, 8 | key CHARACTER VARYING(128) NULL, 9 | value BYTEA NOT NULL, 10 | headers JSONB NULL 11 | ); 12 | 13 | CREATE INDEX idx_outbox_kafka_not_processed ON outbox_kafka (id) WHERE processed IS NULL; 14 | CREATE INDEX idx_outbox_kafka_processed ON outbox_kafka (processed); 15 | 16 | CREATE TABLE IF NOT EXISTS outbox_kafka_lock ( 17 | id CHARACTER VARYING(32) PRIMARY KEY, 18 | owner_id CHARACTER VARYING(128) NOT NULL, 19 | valid_until TIMESTAMP WITHOUT TIME ZONE NOT NULL 20 | ); 21 | -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/resources/hibernate-types.properties: -------------------------------------------------------------------------------- 1 | hibernate.types.print.banner=false -------------------------------------------------------------------------------- /outbox-kafka-spring/src/test/resources/log4j2-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "transactional-outbox" 2 | include("commons") 3 | include("outbox-kafka-spring") 4 | include("outbox-kafka-spring-reactive") 5 | -------------------------------------------------------------------------------- /update_copyright_headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright 2023 Tomorrow GmbH @ https://tomorrow.one 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | # Shell script that can be used to update copyright headers 19 | # for files that have been updated during this year 20 | # determined by the git diff command. 21 | 22 | year=$(date +'%Y') 23 | 24 | echo "Updating copyright headers for all java files that have been changed in $year" 25 | # Changed in the specified year and committed 26 | for file in $(git log --pretty='%aI %H' \ 27 | |awk -v year="$year" '$1 >= year"-01-01" && $1 <= year"-12-31" { print $2 }' | git --no-pager log --no-walk --name-only --stdin | grep -E "^.+\.(java)$"| sort | uniq ); do 28 | echo "Updating file $file" 29 | sed -i "" "s/Copyright \([0-9]\{4\}\)\(-[0-9]\{4\}\)\{0,1\} Tomorrow GmbH/Copyright \1-$year Tomorrow GmbH/g" "$file"; 30 | done 31 | --------------------------------------------------------------------------------