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 |
--------------------------------------------------------------------------------