Skip to content

Commit 5bfef8d

Browse files
feat: Add ReassignmentHandler which is notified on client reassignment (#886)
This enables clients to respond to a reassignment by cancelling outstanding actions and nacking all messages. Fixes #869
1 parent 5abd97d commit 5bfef8d

File tree

7 files changed

+175
-42
lines changed

7 files changed

+175
-42
lines changed

google-cloud-pubsublite/clirr-ignored-differences.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@
164164
<className>com/google/cloud/pubsublite/cloudpubsub/internal/**</className>
165165
<method>*</method>
166166
</difference>
167+
<difference>
168+
<differenceType>7004</differenceType>
169+
<className>com/google/cloud/pubsublite/cloudpubsub/internal/**</className>
170+
<method>*</method>
171+
</difference>
167172
<difference>
168173
<differenceType>7005</differenceType>
169174
<className>com/google/cloud/pubsublite/cloudpubsub/internal/**</className>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2021 Google LLC
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+
17+
package com.google.cloud.pubsublite.cloudpubsub;
18+
19+
import com.google.cloud.pubsublite.Partition;
20+
import java.util.Set;
21+
22+
/**
23+
* A ReassignmentHandler is called any time a new partition assignment is received from the server.
24+
* It will be called with both the previous and new assignments as decided by the backend.
25+
*
26+
* <p>The client library will not acknowledge the assignment until handleReassignment returns. The
27+
* assigning backend will not assign any of the partitions in `before` to another server unless the
28+
* assignment is acknowledged, or a client takes too long to acknowledged (currently 30 seconds from
29+
* the time the assignment is sent from server's point of view).
30+
*
31+
* <p>Because of the above, as long as reassignment handling is processed quickly, it can be used to
32+
* abort outstanding operations on partitions which are being assigned away from this client, or to
33+
* pre-warm state which will be used by the MessageReceiver.
34+
*/
35+
public interface ReassignmentHandler {
36+
/**
37+
* Called with the previous and new assignment delivered to this client on an assignment change.
38+
* The assignment will not be acknowledged until this method returns, so it should complete
39+
* quickly, or the backend will assume it is non-responsive and assign all partitions away without
40+
* waiting for acknowledgement.
41+
*
42+
* <p>handleReassignment will only be called after no new messages will be delivered for the
43+
* partition.
44+
*
45+
* <p>Acks or nacks on messages from partitions being assigned away will have no effect.
46+
*
47+
* @param before the previous assignment
48+
* @param after the new assignment
49+
*/
50+
void handleReassignment(Set<Partition> before, Set<Partition> after);
51+
}

google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,16 @@ public abstract class SubscriberSettings {
126126
*/
127127
abstract Optional<NackHandler> nackHandler();
128128

129+
/** A handler that will be notified when partition assignments change from the backend. */
130+
abstract ReassignmentHandler reassignmentHandler();
131+
129132
public static Builder newBuilder() {
130133
return new AutoValue_SubscriberSettings.Builder()
131134
.setFramework(Framework.of("CLOUD_PUBSUB_SHIM"))
132135
.setPartitions(ImmutableList.of())
133136
.setCredentialsProvider(
134-
SubscriberServiceSettings.defaultCredentialsProviderBuilder().build());
137+
SubscriberServiceSettings.defaultCredentialsProviderBuilder().build())
138+
.setReassignmentHandler((before, after) -> {});
135139
}
136140

137141
@AutoValue.Builder
@@ -203,6 +207,9 @@ public abstract Builder setSubscriberServiceClientSupplier(
203207
*/
204208
public abstract Builder setNackHandler(NackHandler nackHandler);
205209

210+
/** A handler that will be notified when partition assignments change from the backend. */
211+
public abstract Builder setReassignmentHandler(ReassignmentHandler reassignmentHandler);
212+
206213
public abstract SubscriberSettings build();
207214
}
208215

@@ -301,7 +308,8 @@ Subscriber instantiate() throws ApiException {
301308
.setServiceClient(getAssignmentServiceClient());
302309
AssignerFactory assignerFactory =
303310
receiver -> assignerSettings.setReceiver(receiver).build().instantiate();
304-
return new AssigningSubscriber(partitionSubscriberFactory, assignerFactory);
311+
return new AssigningSubscriber(
312+
partitionSubscriberFactory, reassignmentHandler(), assignerFactory);
305313
}
306314

307315
List<Subscriber> perPartitionSubscribers = new ArrayList<>();

google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/AssigningSubscriber.java

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222

2323
import com.google.api.gax.rpc.ApiException;
2424
import com.google.cloud.pubsublite.Partition;
25+
import com.google.cloud.pubsublite.cloudpubsub.ReassignmentHandler;
2526
import com.google.cloud.pubsublite.cloudpubsub.Subscriber;
2627
import com.google.cloud.pubsublite.internal.CheckedApiException;
27-
import com.google.cloud.pubsublite.internal.CloseableMonitor;
2828
import com.google.cloud.pubsublite.internal.ProxyService;
2929
import com.google.cloud.pubsublite.internal.wire.Assigner;
3030
import com.google.cloud.pubsublite.internal.wire.AssignerFactory;
@@ -36,42 +36,36 @@
3636
import java.util.HashMap;
3737
import java.util.List;
3838
import java.util.Map;
39+
import java.util.Objects;
3940
import java.util.Set;
4041

4142
public class AssigningSubscriber extends ProxyService implements Subscriber {
4243
private static final GoogleLogger LOG = GoogleLogger.forEnclosingClass();
4344
private final PartitionSubscriberFactory subscriberFactory;
45+
private final ReassignmentHandler reassignmentHandler;
4446

45-
private final CloseableMonitor monitor = new CloseableMonitor();
46-
47-
@GuardedBy("monitor.monitor")
47+
@GuardedBy("this")
4848
private final Map<Partition, Subscriber> liveSubscriberMap = new HashMap<>();
4949

50-
@GuardedBy("monitor.monitor")
51-
private final List<Subscriber> stoppingSubscribers = new ArrayList<>();
52-
53-
@GuardedBy("monitor.monitor")
50+
@GuardedBy("this")
5451
private boolean shutdown = false;
5552

5653
public AssigningSubscriber(
57-
PartitionSubscriberFactory subscriberFactory, AssignerFactory assignerFactory)
54+
PartitionSubscriberFactory subscriberFactory,
55+
ReassignmentHandler reassignmentHandler,
56+
AssignerFactory assignerFactory)
5857
throws ApiException {
5958
this.subscriberFactory = subscriberFactory;
59+
this.reassignmentHandler = reassignmentHandler;
6060
Assigner assigner = assignerFactory.New(this::handleAssignment);
6161
addServices(assigner);
6262
}
6363

6464
@Override
65-
protected void start() {}
66-
67-
@Override
68-
protected void stop() {
69-
try (CloseableMonitor.Hold h = monitor.enter()) {
70-
shutdown = true;
71-
blockingShutdown(liveSubscriberMap.values());
72-
liveSubscriberMap.clear();
73-
blockingShutdown(stoppingSubscribers);
74-
}
65+
protected synchronized void stop() {
66+
shutdown = true;
67+
blockingShutdown(liveSubscriberMap.values());
68+
liveSubscriberMap.clear();
7569
}
7670

7771
@Override
@@ -81,25 +75,30 @@ protected void handlePermanentError(CheckedApiException error) {
8175

8276
private void handleAssignment(Set<Partition> assignment) {
8377
try {
84-
try (CloseableMonitor.Hold h = monitor.enter()) {
78+
Set<Partition> livePartitions;
79+
List<Subscriber> removed = new ArrayList<>();
80+
synchronized (this) {
8581
if (shutdown) return;
86-
Set<Partition> livePartitions = ImmutableSet.copyOf(liveSubscriberMap.keySet());
82+
livePartitions = ImmutableSet.copyOf(liveSubscriberMap.keySet());
8783
for (Partition partition : livePartitions) {
8884
if (!assignment.contains(partition)) {
89-
stopSubscriber(liveSubscriberMap.remove(partition));
85+
removed.add(Objects.requireNonNull(liveSubscriberMap.remove(partition)));
9086
}
9187
}
9288
for (Partition partition : assignment) {
9389
if (!liveSubscriberMap.containsKey(partition)) startSubscriber(partition);
9490
}
9591
}
92+
blockingShutdown(removed);
93+
// Call reassignment handler outside lock so it won't deadlock if it is asynchronously
94+
// reentrant such as by calling sub.stopAsync().awaitTerminated().
95+
reassignmentHandler.handleReassignment(livePartitions, assignment);
9696
} catch (Throwable t) {
9797
onPermanentError(toCanonical(t));
9898
}
9999
}
100100

101-
@GuardedBy("monitor.monitor")
102-
private void startSubscriber(Partition partition) throws CheckedApiException {
101+
private synchronized void startSubscriber(Partition partition) throws CheckedApiException {
103102
checkState(!liveSubscriberMap.containsKey(partition));
104103
Subscriber subscriber = subscriberFactory.newSubscriber(partition);
105104
subscriber.addListener(
@@ -109,22 +108,9 @@ public void failed(State from, Throwable failure) {
109108
if (State.STOPPING.equals(from)) return;
110109
onPermanentError(toCanonical(failure));
111110
}
112-
113-
@Override
114-
public void terminated(State from) {
115-
try (CloseableMonitor.Hold h = monitor.enter()) {
116-
stoppingSubscribers.remove(subscriber);
117-
}
118-
}
119111
},
120112
SystemExecutors.getFuturesExecutor());
121113
liveSubscriberMap.put(partition, subscriber);
122114
subscriber.startAsync();
123115
}
124-
125-
@GuardedBy("monitor.monitor")
126-
private void stopSubscriber(Subscriber subscriber) {
127-
stoppingSubscribers.add(subscriber);
128-
subscriber.stopAsync();
129-
}
130116
}

google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/SinglePartitionSubscriber.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ protected void start() throws CheckedApiException {
7878
@Override
7979
protected void stop() {}
8080

81+
private boolean terminated() {
82+
return State.TERMINATED.equals(state());
83+
}
84+
8185
@VisibleForTesting
8286
void onMessages(List<SequencedMessage> sequencedMessages) {
8387
try {
@@ -89,6 +93,9 @@ void onMessages(List<SequencedMessage> sequencedMessages) {
8993
new AckReplyConsumer() {
9094
@Override
9195
public void ack() {
96+
if (terminated()) {
97+
return; // Drop acks after shutdown
98+
}
9299
trackerConsumer.run();
93100
try {
94101
wireSubscriber.allowFlow(
@@ -103,6 +110,9 @@ public void ack() {
103110

104111
@Override
105112
public void nack() {
113+
if (terminated()) {
114+
return; // Drop nacks after shutdown to allow nacking from reassignment handler
115+
}
106116
ApiFuture<Void> nackDone = nackHandler.nack(userMessage);
107117
ApiFutures.addCallback(
108118
nackDone,

google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/internal/AssigningSubscriberTest.java

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@
2020
import static com.google.common.truth.Truth.assertThat;
2121
import static org.junit.Assert.assertThrows;
2222
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.Mockito.doAnswer;
24+
import static org.mockito.Mockito.doThrow;
25+
import static org.mockito.Mockito.inOrder;
2326
import static org.mockito.Mockito.reset;
2427
import static org.mockito.Mockito.spy;
2528
import static org.mockito.Mockito.verify;
2629
import static org.mockito.Mockito.verifyNoMoreInteractions;
2730
import static org.mockito.Mockito.when;
2831
import static org.mockito.MockitoAnnotations.initMocks;
2932

33+
import com.google.api.core.ApiService.State;
3034
import com.google.cloud.pubsublite.Partition;
35+
import com.google.cloud.pubsublite.cloudpubsub.ReassignmentHandler;
3136
import com.google.cloud.pubsublite.cloudpubsub.Subscriber;
3237
import com.google.cloud.pubsublite.internal.CheckedApiException;
3338
import com.google.cloud.pubsublite.internal.testing.FakeApiService;
@@ -36,17 +41,21 @@
3641
import com.google.cloud.pubsublite.internal.wire.PartitionAssignmentReceiver;
3742
import com.google.common.collect.ImmutableSet;
3843
import io.grpc.Status;
44+
import java.util.Set;
3945
import java.util.concurrent.Future;
4046
import org.junit.Before;
4147
import org.junit.Test;
4248
import org.junit.runner.RunWith;
4349
import org.junit.runners.JUnit4;
50+
import org.mockito.InOrder;
4451
import org.mockito.Mock;
4552
import org.mockito.Spy;
53+
import org.mockito.stubbing.Answer;
4654

4755
@RunWith(JUnit4.class)
4856
public class AssigningSubscriberTest {
4957
@Mock PartitionSubscriberFactory subscriberFactory;
58+
@Mock ReassignmentHandler reassignmentHandler;
5059
@Mock AssignerFactory assignerFactory;
5160

5261
private AssigningSubscriber assigningSubscriber;
@@ -68,7 +77,8 @@ public void setUp() {
6877
leakedReceiver = args.getArgument(0);
6978
return assigner;
7079
});
71-
assigningSubscriber = new AssigningSubscriber(subscriberFactory, assignerFactory);
80+
assigningSubscriber =
81+
new AssigningSubscriber(subscriberFactory, reassignmentHandler, assignerFactory);
7282
verify(assignerFactory).New(any());
7383
assertThat(leakedReceiver).isNotNull();
7484
assigningSubscriber.startAsync().awaitRunning();
@@ -89,19 +99,52 @@ public void failedCreate() throws CheckedApiException {
8999
assertThrows(IllegalStateException.class, assigningSubscriber::awaitTerminated);
90100
}
91101

102+
@Test
103+
public void assignmentHandlerFailure() throws Exception {
104+
Subscriber sub1 = spy(FakeSubscriber.class);
105+
when(subscriberFactory.newSubscriber(Partition.of(1))).thenReturn(sub1);
106+
doThrow(new RuntimeException("Arbitrary error."))
107+
.when(reassignmentHandler)
108+
.handleReassignment(ImmutableSet.of(), ImmutableSet.of(Partition.of(1)));
109+
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(1)));
110+
assertThrows(IllegalStateException.class, assigningSubscriber::awaitTerminated);
111+
}
112+
113+
@Test
114+
public void assignmentHandlerReentrantSafe() throws Exception {
115+
Subscriber sub1 = spy(FakeSubscriber.class);
116+
when(subscriberFactory.newSubscriber(Partition.of(1))).thenReturn(sub1);
117+
doAnswer(
118+
(Answer<Void>)
119+
args -> {
120+
assigningSubscriber.stopAsync().awaitTerminated();
121+
return null;
122+
})
123+
.when(reassignmentHandler)
124+
.handleReassignment(ImmutableSet.of(), ImmutableSet.of(Partition.of(1)));
125+
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(1)));
126+
assigningSubscriber.awaitTerminated();
127+
}
128+
92129
@Test
93130
public void createSubscribers() throws CheckedApiException {
94131
Subscriber sub1 = spy(FakeSubscriber.class);
95132
when(subscriberFactory.newSubscriber(Partition.of(1))).thenReturn(sub1);
96133
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(1)));
97-
verify(subscriberFactory).newSubscriber(Partition.of(1));
134+
InOrder order = inOrder(reassignmentHandler, subscriberFactory);
135+
order.verify(subscriberFactory).newSubscriber(Partition.of(1));
136+
order
137+
.verify(reassignmentHandler)
138+
.handleReassignment(ImmutableSet.of(), ImmutableSet.of(Partition.of(1)));
98139
verify(sub1).startAsync();
99140
reset(sub1);
100141

101142
Subscriber sub2 = spy(FakeSubscriber.class);
102143
when(subscriberFactory.newSubscriber(Partition.of(2))).thenReturn(sub2);
103-
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(1), Partition.of(2)));
144+
Set<Partition> newAssignment = ImmutableSet.of(Partition.of(1), Partition.of(2));
145+
leakedReceiver.handleAssignment(newAssignment);
104146
verify(subscriberFactory).newSubscriber(Partition.of(2));
147+
verify(reassignmentHandler).handleReassignment(ImmutableSet.of(Partition.of(1)), newAssignment);
105148
verify(sub2).startAsync();
106149
verifyNoMoreInteractions(sub1);
107150
}
@@ -112,15 +155,31 @@ public void createAndEvict() throws CheckedApiException {
112155
when(subscriberFactory.newSubscriber(Partition.of(1))).thenReturn(sub1);
113156
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(1)));
114157
verify(subscriberFactory).newSubscriber(Partition.of(1));
158+
verify(reassignmentHandler)
159+
.handleReassignment(ImmutableSet.of(), ImmutableSet.of(Partition.of(1)));
115160
verify(sub1).startAsync();
161+
State sub1State = sub1.state();
162+
assertThat(State.STARTING.equals(sub1State) || State.RUNNING.equals(sub1State)).isTrue();
116163
reset(sub1);
117164

118165
Subscriber sub2 = spy(FakeSubscriber.class);
119166
when(subscriberFactory.newSubscriber(Partition.of(2))).thenReturn(sub2);
167+
doAnswer(
168+
(Answer<Void>)
169+
args -> {
170+
// sub1 should already be stopped by the time the reassignmentHandler is called
171+
assertThat(sub1.state()).isEqualTo(State.TERMINATED);
172+
return null;
173+
})
174+
.when(reassignmentHandler)
175+
.handleReassignment(ImmutableSet.of(Partition.of(1)), ImmutableSet.of(Partition.of(2)));
120176
leakedReceiver.handleAssignment(ImmutableSet.of(Partition.of(2)));
121177
verify(subscriberFactory).newSubscriber(Partition.of(2));
178+
verify(reassignmentHandler)
179+
.handleReassignment(ImmutableSet.of(Partition.of(1)), ImmutableSet.of(Partition.of(2)));
122180
verify(sub2).startAsync();
123181
verify(sub1).stopAsync();
182+
verify(sub1).awaitTerminated();
124183
}
125184

126185
private Subscriber initSub1() throws CheckedApiException {

0 commit comments

Comments
 (0)