Skip to content

Commit 1e284c8

Browse files
author
Paŭlo Ebermann
authored
Merge pull request #164 from zalando-nakadi/154-key-extractor-bean-experiment
support compacted event types (#154) via spring beans
2 parents ae433d1 + aff88fb commit 1e284c8

File tree

17 files changed

+590
-159
lines changed

17 files changed

+590
-159
lines changed

README.md

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
[Nakadi](https://github.com/zalando/nakadi) is a distributed event bus that implements a RESTful API abstraction instead of Kafka-like queues.
99

10-
The goal of this Spring Boot starter is to simplify the reliable integration between event producer and Nakadi. When we send events from a transactional application, a few recurring challenges appear:
10+
The goal of **this** Spring Boot starter is to simplify the reliable integration between event producer and Nakadi. When we send events from a transactional application, a few recurring challenges appear:
1111
- we have to make sure that events from a transaction get sent, when the transaction has been committed,
1212
- we have to make sure that events from a transaction do not get sent, when the transaction has been rolled back,
1313
- we have to make sure that events get sent, even if an error occurred while sending the event,
@@ -24,7 +24,8 @@ This project is mature, used in production in some services at Zalando, and in a
2424

2525
Be aware that this library **does neither guarantee that events are sent exactly once, nor that they are sent in the order they have been persisted**. This is not a bug but a design decision that allows us to skip and retry sending events later in case of temporary failures. So make sure that your events are designed to be processed out of order (See [Rule 203 in Zalando's API guidelines](https://opensource.zalando.com/restful-api-guidelines/#203)). To help you in this matter, the library generates a *strictly monotonically increasing event id* (field `metadata/eid` in Nakadi's event object) that can be used to reconstruct the message order.
2626

27-
Unfortunately this approach is not compatible with Nakadi's compacted event types – it can happen that the last event submitted (and thus the one which will stay after compaction) is not the last event which was actually been fired. For this reason, the library currently also doesn't provide any access to Nakadi's [`partition_compaction_key`](https://nakadi.io/manual.html#definition_EventMetadata*partition_compaction_key) feature.
27+
Unfortunately this approach is fundamentally incompatible with Nakadi's compacted event types – it can happen that the last event submitted (and thus the one which will stay after compaction) is not the last event which was actually been fired.
28+
We still provide means to set the compaction key, see [compacted event types](#compacted-event-types) below.
2829

2930
## Versioning
3031

@@ -107,7 +108,7 @@ token. The easiest way to do so is to include the [Zalando Tokens library](https
107108
</dependency>
108109
```
109110

110-
This starter will detect and auto configure it.
111+
This starter will detect and autoconfigure it.
111112

112113
If your application is running in Zalando's Kubernetes environment, you have to configure the credential rotation:
113114
```yaml
@@ -158,16 +159,18 @@ nakadi-producer:
158159
```
159160

160161
#### Implement Nakadi authentication yourself
161-
If you do not use the STUPS Tokens library, you can implement token retrieval yourself by defining a Spring bean of type `org.zalando.nakadiproducer.AccessTokenProvider`. The starter will detect it and call it once for each request to retrieve the token.
162+
If you do not use the STUPS Tokens library, you can implement token retrieval yourself by defining a Spring bean of
163+
type [`AccessTokenProvider`](nakadi-producer-spring-boot-starter/src/main/java/org/zalando/nakadiproducer/AccessTokenProvider.java).
164+
The starter will detect it and call it once for each request to retrieve the token.
162165

163166
### Creating events
164167

165168
The typical use case for this library is to publish events like creating or updating of some objects.
166169

167-
In order to store events you can autowire the [`EventLogWriter`](src/main/java/org/zalando/nakadiproducer/eventlog/EventLogWriter.java)
170+
In order to store events you can autowire the [`EventLogWriter`](nakadi-producer/src/main/java/org/zalando/nakadiproducer/eventlog/EventLogWriter.java)
168171
service and use its methods: `fireCreateEvent`, `fireUpdateEvent`, `fireDeleteEvent`, `fireSnapshotEvent` or `fireBusinessEvent`.
169172

170-
To store events in bulk the methods `fireCreateEvents`, `fireUpdateEvents`, `fireDeleteEvents`, `fireSnapshotEvents` or `fireBusinessEvents` can be used.
173+
To store several events of the same type in bulk, the methods `fireCreateEvents`, `fireUpdateEvents`, `fireDeleteEvents`, `fireSnapshotEvents` or `fireBusinessEvents` can be used.
171174

172175
You normally don't need to call `fireSnapshotEvent` directly, see below for [snapshot creation](#event-snapshots-optional).
173176

@@ -222,14 +225,50 @@ For business events, you have just two parameters, the **eventType** and the eve
222225
You usually should fire those also in the same transaction as you are storing the results of the
223226
process step the event is reporting.
224227

228+
#### Compacted event types
229+
230+
Nakadi offers a "log-compaction" feature, where each event (on an event type) has a
231+
[`partition_compaction_key`](https://nakadi.io/manual.html#definition_EventMetadata*partition_compaction_key), and
232+
Nakadi will (after delivering to live subscribers) clean up events, but leave the latest event for each
233+
compaction key available long-term.
234+
235+
This library (by design) doesn't guarantee the submission order of events – especially when there are problems
236+
on Nakadi side and some events fail (and are retried later), earlier produced events (for the same entity)
237+
can be submitted after later events. For log-compacted event types this means that an outdated event will remain
238+
in the topic for future subscribers to read.
239+
It is therefore generally **not recommended** to use this library (or any solution which doesn't guarantee the order)
240+
for sending events to a compacted event type.
241+
242+
In some cases, like when there usually are large time gaps between producing events for the same compaction key,
243+
the risk of getting events for the same key out-of-order is small.
244+
For these cases, you just can define a bean of type [`CompactionKeyExtractor`](nakadi-producer/src/main/java/org/zalando/nakadiproducer/eventlog/CompactionKeyExtractor.java),
245+
and then all events of that event type will be sent with a compaction key.
246+
247+
```java
248+
@Configuration
249+
public class NakadiProducerConfiguration {
250+
@Bean
251+
public CompactionKeyExtractor extractorForWarehouseEvents() {
252+
return CompactionKeyExtractor.of("wholesale.warehouse-change-event",
253+
Warehouse.class, Warehouse::getCode);
254+
}
255+
}
256+
```
257+
The service class sending the event looks exactly the same as above.
258+
259+
For corner cases: You can have multiple such extractors for the same event type, any one where the class object
260+
matches the payload object (in undefined order) will be used.
261+
There are also some more factory methods with different signatures for more special cases, and you can also write
262+
your own implementation (but for the usual cases, the one shown here should be enough).
263+
225264
### Event snapshots (optional)
226265

227266
A Snapshot event is a special type of data change event (data operation) defined by Nakadi.
228267
It does not represent a change of the state of a resource, but a current snapshot of its state. It can be useful to
229268
bootstrap a new consumer or to recover from inconsistencies between sender and consumer after an incident.
230269

231270
You can create snapshot events programmatically (using EventLogWriter.fireSnapshotEvent), but usually snapshot event
232-
creation is a irregular, manually triggered maintenance task.
271+
creation is an irregular, manually triggered maintenance task.
233272

234273
This library provides a Spring Boot Actuator endpoint named `snapshot_event_creation` that can be used to trigger a Snapshot for a given event type. Assuming your management port is set to `7979`,
235274

@@ -260,6 +299,9 @@ your `application.properties` includes
260299
management.endpoints.web.exposure.include=snapshot-event-creation,your-other-endpoints,...`
261300
```
262301
and if one or more Spring Beans implement the `org.zalando.nakadiproducer.snapshots.SnapshotEventGenerator` interface.
302+
(Note that this will automatically work together with the compaction key feature mentioned above,
303+
if you have registered a compaction key extractor matching the type of the data objects in your snapshots.)
304+
263305
The optional filter specifier of the trigger request will be passed as a string parameter to the
264306
SnapshotEventGenerator's `generateSnapshots` method and may be null, if none is given.
265307

nakadi-producer-spring-boot-starter/src/main/java/org/zalando/nakadiproducer/NakadiProducerAutoConfiguration.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
2424
import org.springframework.scheduling.annotation.EnableScheduling;
2525
import org.zalando.fahrschein.NakadiClient;
26-
import org.zalando.fahrschein.NakadiClientBuilder;
2726
import org.zalando.fahrschein.http.api.ContentEncoding;
2827
import org.zalando.fahrschein.http.api.RequestFactory;
2928
import org.zalando.fahrschein.http.simple.SimpleRequestFactory;
29+
import org.zalando.nakadiproducer.eventlog.CompactionKeyExtractor;
3030
import org.zalando.nakadiproducer.eventlog.EventLogWriter;
3131
import org.zalando.nakadiproducer.eventlog.impl.EventLogRepository;
3232
import org.zalando.nakadiproducer.eventlog.impl.EventLogRepositoryImpl;
@@ -139,8 +139,8 @@ public SnapshotCreationService snapshotCreationService(
139139

140140
@Bean
141141
public EventLogWriter eventLogWriter(EventLogRepository eventLogRepository, ObjectMapper objectMapper,
142-
FlowIdComponent flowIdComponent) {
143-
return new EventLogWriterImpl(eventLogRepository, objectMapper, flowIdComponent);
142+
FlowIdComponent flowIdComponent, List<CompactionKeyExtractor> extractorList) {
143+
return new EventLogWriterImpl(eventLogRepository, objectMapper, flowIdComponent, extractorList);
144144
}
145145

146146
@Bean

nakadi-producer-spring-boot-starter/src/main/java/org/zalando/nakadiproducer/eventlog/impl/EventLogRepositoryImpl.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,17 @@ public void persist(Collection<EventLog> eventLogs) {
8787
namedParameterMap.addValue("lastModified", now);
8888
namedParameterMap.addValue("lockedBy", eventLog.getLockedBy());
8989
namedParameterMap.addValue("lockedUntil", eventLog.getLockedUntil());
90+
namedParameterMap.addValue("compactionKey", eventLog.getCompactionKey());
9091
return namedParameterMap;
9192
})
9293
.toArray(MapSqlParameterSource[]::new);
9394

9495
jdbcTemplate.batchUpdate(
9596
"INSERT INTO " +
9697
" nakadi_events.event_log " +
97-
" (event_type, event_body_data, flow_id, created, last_modified, locked_by, locked_until) " +
98+
" (event_type, event_body_data, flow_id, created, last_modified, locked_by, locked_until, compaction_key)" +
9899
"VALUES " +
99-
" (:eventType, :eventBodyData, :flowId, :created, :lastModified, :lockedBy, :lockedUntil)",
100+
" (:eventType, :eventBodyData, :flowId, :created, :lastModified, :lockedBy, :lockedUntil, :compactionKey)",
100101
namedParameterMaps
101102
);
102103
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE nakadi_events.event_log
2+
ADD COLUMN compaction_key TEXT NULL
3+
;

nakadi-producer-spring-boot-starter/src/test/java/org/zalando/nakadiproducer/EndToEndTestIT.java

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
import static com.jayway.jsonpath.Criteria.where;
44
import static com.jayway.jsonpath.JsonPath.read;
55
import static org.hamcrest.MatcherAssert.assertThat;
6-
import static org.hamcrest.Matchers.empty;
7-
import static org.hamcrest.Matchers.is;
6+
import static org.hamcrest.Matchers.*;
87

98
import java.io.IOException;
109
import java.util.List;
@@ -13,17 +12,23 @@
1312
import org.junit.jupiter.api.BeforeEach;
1413
import org.junit.jupiter.api.Test;
1514
import org.springframework.beans.factory.annotation.Autowired;
15+
import org.springframework.context.annotation.Bean;
16+
import org.springframework.test.context.ContextConfiguration;
17+
import org.zalando.nakadiproducer.eventlog.CompactionKeyExtractor;
1618
import org.zalando.nakadiproducer.eventlog.EventLogWriter;
1719
import org.zalando.nakadiproducer.transmission.MockNakadiPublishingClient;
1820
import org.zalando.nakadiproducer.transmission.impl.EventTransmitter;
1921
import org.zalando.nakadiproducer.util.Fixture;
2022
import org.zalando.nakadiproducer.util.MockPayload;
2123

24+
@ContextConfiguration(classes = EndToEndTestIT.Config.class)
2225
public class EndToEndTestIT extends BaseMockedExternalCommunicationIT {
2326
private static final String MY_DATA_CHANGE_EVENT_TYPE = "myDataChangeEventType";
27+
private static final String SECOND_DATA_CHANGE_EVENT_TYPE = "secondDataChangeEventType";
2428
private static final String MY_BUSINESS_EVENT_TYPE = "myBusinessEventType";
2529
public static final String PUBLISHER_DATA_TYPE = "nakadi:some-publisher";
2630
private static final String CODE = "code123";
31+
public static final String COMPACTION_KEY = "Hello World";
2732

2833
@Autowired
2934
private EventLogWriter eventLogWriter;
@@ -55,6 +60,36 @@ public void dataEventsShouldBeSubmittedToNakadi() throws IOException {
5560
assertThat(read(value.get(0), "$.data.code"), is(CODE));
5661
}
5762

63+
@Test
64+
public void compactionKeyIsPreserved() throws IOException {
65+
MockPayload payload = Fixture.mockPayload(1, CODE);
66+
eventLogWriter.fireDeleteEvent(SECOND_DATA_CHANGE_EVENT_TYPE, PUBLISHER_DATA_TYPE, payload);
67+
eventLogWriter.fireBusinessEvent(MY_BUSINESS_EVENT_TYPE, payload);
68+
69+
eventTransmitter.sendEvents();
70+
71+
List<String> dataEvents = nakadiClient.getSentEvents(SECOND_DATA_CHANGE_EVENT_TYPE);
72+
assertThat(dataEvents.size(), is(1));
73+
assertThat(read(dataEvents.get(0), "$.metadata.partition_compaction_key"), is(COMPACTION_KEY));
74+
75+
List<String> businessEvents = nakadiClient.getSentEvents(MY_BUSINESS_EVENT_TYPE);
76+
assertThat(businessEvents.size(), is(1));
77+
assertThat(read(businessEvents.get(0), "$.metadata.partition_compaction_key"), is(CODE));
78+
}
79+
80+
@Test
81+
public void compactionKeyIsNotInvented() throws IOException {
82+
MockPayload payload = Fixture.mockPayload(1, CODE);
83+
eventLogWriter.fireDeleteEvent(MY_DATA_CHANGE_EVENT_TYPE, PUBLISHER_DATA_TYPE, payload);
84+
85+
eventTransmitter.sendEvents();
86+
List<String> value = nakadiClient.getSentEvents(MY_DATA_CHANGE_EVENT_TYPE);
87+
88+
assertThat(value.size(), is(1));
89+
assertThat(read(value.get(0), "$.metadata[?]", where("partition_compaction_key").exists(true)),
90+
is(empty()));
91+
}
92+
5893
@Test
5994
public void businessEventsShouldBeSubmittedToNakadi() throws IOException {
6095
MockPayload payload = Fixture.mockPayload(1, CODE);
@@ -75,4 +110,16 @@ public void businessEventsShouldBeSubmittedToNakadi() throws IOException {
75110
assertThat(read(value.get(0), "$[?]", where("data_type").exists(true)), is(empty()));
76111
assertThat(read(value.get(0), "$[?]", where("data").exists(true)), is(empty()));
77112
}
113+
114+
public static class Config {
115+
@Bean
116+
public CompactionKeyExtractor compactionKeyExtractorForSecondDataEventType() {
117+
return CompactionKeyExtractor.of(SECOND_DATA_CHANGE_EVENT_TYPE, MockPayload.class, m -> COMPACTION_KEY);
118+
}
119+
120+
@Bean
121+
public CompactionKeyExtractor keyExtractorForBusinessEventType() {
122+
return CompactionKeyExtractor.of(MY_BUSINESS_EVENT_TYPE, MockPayload.class, MockPayload::getCode);
123+
}
124+
}
78125
}

nakadi-producer-spring-boot-starter/src/test/java/org/zalando/nakadiproducer/eventlog/impl/EventLogRepositoryIT.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,22 @@ public class EventLogRepositoryIT extends BaseMockedExternalCommunicationIT {
3838

3939
private final String WAREHOUSE_EVENT_TYPE = "wholesale.warehouse-change-event";
4040

41+
public static final String COMPACTION_KEY = "COMPACTED";
42+
4143
@BeforeEach
4244
public void setUp() {
4345
eventLogRepository.deleteAll();
4446

45-
final EventLog eventLog = EventLog.builder().eventBodyData(WAREHOUSE_EVENT_BODY_DATA)
47+
final EventLog eventLog = EventLog.builder()
48+
.eventBodyData(WAREHOUSE_EVENT_BODY_DATA)
4649
.eventType(WAREHOUSE_EVENT_TYPE)
50+
.compactionKey(COMPACTION_KEY)
4751
.flowId("FLOW_ID").build();
4852
eventLogRepository.persist(eventLog);
4953
}
5054

5155
@Test
52-
public void findEventRepositoryId() {
56+
public void testFindEventInRepositoryById() {
5357
Integer id = jdbcTemplate.queryForObject(
5458
"SELECT id FROM nakadi_events.event_log WHERE flow_id = 'FLOW_ID'",
5559
Integer.class);
@@ -60,6 +64,7 @@ public void findEventRepositoryId() {
6064
private void compareWithPersistedEvent(final EventLog eventLog) {
6165
assertThat(eventLog.getEventBodyData(), is(WAREHOUSE_EVENT_BODY_DATA));
6266
assertThat(eventLog.getEventType(), is(WAREHOUSE_EVENT_TYPE));
67+
assertThat(eventLog.getCompactionKey(), is(COMPACTION_KEY));
6368
}
6469

6570
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.zalando.nakadiproducer.eventlog;
2+
3+
import org.zalando.nakadiproducer.eventlog.CompactionKeyExtractors.SimpleCompactionKeyExtractor;
4+
import org.zalando.nakadiproducer.eventlog.CompactionKeyExtractors.TypedCompactionKeyExtractor;
5+
6+
import java.util.Optional;
7+
import java.util.function.Function;
8+
9+
/**
10+
* This interface defines a way of extracting a compaction key from an object which
11+
* is sent as a payload in a compacted event type.
12+
* In most cases, for each compacted event type exactly one such object will be made known to the producer, and
13+
* you can define it using {@link #of(String, Class, Function)}, passing a method reference or a lambda.
14+
* For special occasions (e.g. where objects of different classes are used as payloads for the same event type)
15+
* also multiple extractors for the same event type are supported – in this case any which returns a
16+
* non-empty optional will be used.
17+
*/
18+
public interface CompactionKeyExtractor {
19+
20+
default String getKeyOrNull(Object payload) {
21+
return tryGetKeyFor(payload).orElse(null);
22+
}
23+
24+
Optional<String> tryGetKeyFor(Object o);
25+
26+
String getEventType();
27+
28+
/**
29+
* A type-safe compaction key extractor. This will be the one to be used by most applications.
30+
*
31+
* @param eventType Indicates the event type. Only events sent to this event type will be considered.
32+
* @param type A Java type for payload objects. Only payload objects where {@code type.isInstance(payload)}
33+
* will be considered at all.
34+
* @param extractorFunction A function extracting a compaction key from a payload object.
35+
* This will commonly be given as a method reference or lambda.
36+
* @return A compaction key extractor, to be defined as a spring bean (if using the spring-boot starter)
37+
* or passed manually to the event log writer implementation (if using nakadi-producer directly).
38+
* (This should not return null.)
39+
* @param <X> the type of {@code type} and input type of {@code extractorFunction}.
40+
*/
41+
static <X> CompactionKeyExtractor of(String eventType, Class<X> type, Function<X, String> extractorFunction) {
42+
return new TypedCompactionKeyExtractor<>(eventType, type, extractorFunction);
43+
}
44+
45+
/**
46+
* Non-type safe key extractor, returning an Optional.
47+
* @param eventType The event type for which this extractor is intended.
48+
* @param extractor The extractor function. It is supposed to return {@link Optional#empty()} if this extractor
49+
* can't handle the input object, otherwise the actual key.
50+
* @return a key extractor object.
51+
*/
52+
static CompactionKeyExtractor ofOptional(String eventType, Function<Object, Optional<String>> extractor) {
53+
return new SimpleCompactionKeyExtractor(eventType, extractor);
54+
}
55+
56+
/**
57+
* Non-type safe key extractor, returning null for unknown objects.
58+
* @param eventType The event type for which this extractor is intended.
59+
* @param extractor The extractor function. It is supposed to return {@code null} if this extractor
60+
* can't handle the input object, otherwise the actual key.
61+
* @return a key extractor object.
62+
*/
63+
static CompactionKeyExtractor ofNullable(String eventType, Function<Object, String> extractor) {
64+
return new SimpleCompactionKeyExtractor(eventType, extractor.andThen(Optional::ofNullable));
65+
}
66+
67+
/**
68+
* An universal key extractor, capable of handling all objects.
69+
* @param eventType The event type for which this extractor is intended.
70+
* @param extractor The extractor function. It is not allowed to return {@code null}.
71+
* @return a key extractor object.
72+
*/
73+
static CompactionKeyExtractor of(String eventType, Function<Object, String> extractor) {
74+
return new SimpleCompactionKeyExtractor(eventType, extractor.andThen(Optional::of));
75+
}
76+
}

0 commit comments

Comments
 (0)