Skip to content

Commit e1f6ab7

Browse files
authored
Fix Audit Message Logging Interceptor Race Condition (#938)
* Fix race condition in GrpcMessageInterceptor to revert a empty message if message cannot be recorded. * Fix GrpcMessageInterceptor race condition by allow audit log message to be called from multiple async calls * Revert option change * Add CoreLoggingIT integration test to test message audit logging. * Fix GrpcMessageInterceptor race condition by moving allowing request to be unset. * Fix lint * Increase wait for logs in CoreLoggingIT * Fix to compare the correct lob JsonObject with the right response. * Debug response * Reduce load size to make test less flaky * Update test to only check request and response for one call. * Fix compile failure due to uncaught exception. * Add method name filter to prevent logs from tests from interfering with each other. * Add intergration test to check that message logs are produced correctly under load. * Fix imports and log4j2 not able to find config file * Fix issue with CoreLoggingIT TestLogAppender being null due to class not found by log4j2 * Remove unused getters in MessageAuditLogEntry. * Update CoreLoggingIT test to check that expected contents of logs produced under load.
1 parent eb150a2 commit e1f6ab7

4 files changed

Lines changed: 359 additions & 1 deletion

File tree

common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ public GrpcMessageInterceptor(@Nullable SecurityProperties securityProperties) {
6161
public <ReqT, RespT> Listener<ReqT> interceptCall(
6262
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
6363
MessageAuditLogEntry.Builder entryBuilder = MessageAuditLogEntry.newBuilder();
64-
// default response message to empty proto in log entry.
64+
// default response/request message to empty proto in log entry.
65+
// request could be empty when the client closes the connection before sending a request
66+
// message.
67+
// response could be unset when the service encounters an error when processsing the service
68+
// call.
69+
entryBuilder.setRequest(Empty.newBuilder().build());
6570
entryBuilder.setResponse(Empty.newBuilder().build());
6671

6772
// Unpack service & method name from call
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
* Copyright 2018-2020 The Feast Authors
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+
* https://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+
package feast.core.logging;
18+
19+
import static org.hamcrest.CoreMatchers.*;
20+
import static org.hamcrest.MatcherAssert.assertThat;
21+
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertTrue;
23+
24+
import com.google.common.collect.Streams;
25+
import com.google.common.util.concurrent.Futures;
26+
import com.google.common.util.concurrent.ListenableFuture;
27+
import com.google.gson.JsonObject;
28+
import com.google.gson.JsonParser;
29+
import com.google.protobuf.InvalidProtocolBufferException;
30+
import com.google.protobuf.util.JsonFormat;
31+
import feast.common.it.BaseIT;
32+
import feast.common.it.DataGenerator;
33+
import feast.common.logging.entry.AuditLogEntryKind;
34+
import feast.proto.core.CoreServiceGrpc;
35+
import feast.proto.core.CoreServiceGrpc.CoreServiceBlockingStub;
36+
import feast.proto.core.CoreServiceGrpc.CoreServiceFutureStub;
37+
import feast.proto.core.CoreServiceProto.GetFeastCoreVersionRequest;
38+
import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest;
39+
import feast.proto.core.CoreServiceProto.ListStoresRequest;
40+
import feast.proto.core.CoreServiceProto.ListStoresResponse;
41+
import feast.proto.core.CoreServiceProto.UpdateStoreRequest;
42+
import feast.proto.core.CoreServiceProto.UpdateStoreResponse;
43+
import io.grpc.Channel;
44+
import io.grpc.ManagedChannelBuilder;
45+
import io.grpc.Status.Code;
46+
import io.grpc.StatusRuntimeException;
47+
import java.util.LinkedList;
48+
import java.util.List;
49+
import java.util.concurrent.ExecutionException;
50+
import java.util.stream.Collectors;
51+
import org.apache.commons.lang3.tuple.Pair;
52+
import org.apache.logging.log4j.LogManager;
53+
import org.apache.logging.log4j.core.LoggerContext;
54+
import org.junit.jupiter.api.BeforeAll;
55+
import org.junit.jupiter.api.Test;
56+
import org.springframework.beans.factory.annotation.Value;
57+
import org.springframework.boot.test.context.SpringBootTest;
58+
59+
@SpringBootTest(
60+
properties = {
61+
"feast.logging.audit.enabled=true",
62+
"feast.logging.audit.messageLoggingEnabled=true",
63+
})
64+
public class CoreLoggingIT extends BaseIT {
65+
private static TestLogAppender testAuditLogAppender;
66+
private static CoreServiceBlockingStub coreService;
67+
private static CoreServiceFutureStub asyncCoreService;
68+
69+
@BeforeAll
70+
public static void globalSetUp(@Value("${grpc.server.port}") int coreGrpcPort)
71+
throws InterruptedException, ExecutionException {
72+
LoggerContext logContext = (LoggerContext) LogManager.getContext(false);
73+
// NOTE: As log appender state is shared across tests use a different method
74+
// for each test and filter by method name to ensure that you only get logs
75+
// for a specific test.
76+
testAuditLogAppender = logContext.getConfiguration().getAppender("TestAuditLogAppender");
77+
78+
// Connect to core service.
79+
Channel channel =
80+
ManagedChannelBuilder.forAddress("localhost", coreGrpcPort).usePlaintext().build();
81+
coreService = CoreServiceGrpc.newBlockingStub(channel);
82+
asyncCoreService = CoreServiceGrpc.newFutureStub(channel);
83+
84+
// Preflight a request to core service stubs to verify connection
85+
coreService.getFeastCoreVersion(GetFeastCoreVersionRequest.getDefaultInstance());
86+
asyncCoreService.getFeastCoreVersion(GetFeastCoreVersionRequest.getDefaultInstance()).get();
87+
}
88+
89+
/** Check that messsage audit log are produced on service call */
90+
@Test
91+
public void shouldProduceMessageAuditLogsOnCall()
92+
throws InterruptedException, InvalidProtocolBufferException {
93+
// Generate artifical load on feast core.
94+
UpdateStoreRequest request =
95+
UpdateStoreRequest.newBuilder().setStore(DataGenerator.getDefaultStore()).build();
96+
UpdateStoreResponse response = coreService.updateStore(request);
97+
98+
// Wait required to ensure audit logs are flushed into test audit log appender
99+
Thread.sleep(1000);
100+
// Check message audit logs are produced for each audit log.
101+
JsonFormat.Parser protoJSONParser = JsonFormat.parser();
102+
// Pull message audit logs logs from test log appender
103+
List<JsonObject> logJsonObjects =
104+
parseMessageJsonLogObjects(testAuditLogAppender.getLogs(), "UpdateStore");
105+
assertEquals(1, logJsonObjects.size());
106+
JsonObject logObj = logJsonObjects.get(0);
107+
108+
// Extract & Check that request/response are returned correctly
109+
String requestJson = logObj.getAsJsonObject("request").toString();
110+
UpdateStoreRequest.Builder gotRequest = UpdateStoreRequest.newBuilder();
111+
protoJSONParser.merge(requestJson, gotRequest);
112+
113+
String responseJson = logObj.getAsJsonObject("response").toString();
114+
UpdateStoreResponse.Builder gotResponse = UpdateStoreResponse.newBuilder();
115+
protoJSONParser.merge(responseJson, gotResponse);
116+
117+
assertThat(gotRequest.build(), equalTo(request));
118+
assertThat(gotResponse.build(), equalTo(response));
119+
}
120+
121+
/** Check that message audit logs are produced when server encounters an error */
122+
@Test
123+
public void shouldProduceMessageAuditLogsOnError() throws InterruptedException {
124+
// Send a bad request which should cause Core to error
125+
ListFeatureSetsRequest request =
126+
ListFeatureSetsRequest.newBuilder()
127+
.setFilter(
128+
ListFeatureSetsRequest.Filter.newBuilder()
129+
.setProject("*")
130+
.setFeatureSetName("nop")
131+
.build())
132+
.build();
133+
134+
boolean hasExpectedException = false;
135+
Code statusCode = null;
136+
try {
137+
coreService.listFeatureSets(request);
138+
} catch (StatusRuntimeException e) {
139+
hasExpectedException = true;
140+
statusCode = e.getStatus().getCode();
141+
}
142+
assertTrue(hasExpectedException);
143+
144+
// Wait required to ensure audit logs are flushed into test audit log appender
145+
Thread.sleep(1000);
146+
// Pull message audit logs logs from test log appender
147+
List<JsonObject> logJsonObjects =
148+
parseMessageJsonLogObjects(testAuditLogAppender.getLogs(), "ListFeatureSets");
149+
150+
assertEquals(1, logJsonObjects.size());
151+
JsonObject logJsonObject = logJsonObjects.get(0);
152+
// Check correct status code is tracked on error.
153+
assertEquals(logJsonObject.get("statusCode").getAsString(), statusCode.toString());
154+
}
155+
156+
/** Check that expected message audit logs are produced when under load. */
157+
@Test
158+
public void shouldProduceExpectedAuditLogsUnderLoad()
159+
throws InterruptedException, ExecutionException {
160+
// Generate artifical requests on core to simulate load.
161+
int LOAD_SIZE = 40; // Total number of requests to send.
162+
int BURST_SIZE = 5; // Number of requests to send at once.
163+
164+
ListStoresRequest request = ListStoresRequest.getDefaultInstance();
165+
List<ListStoresResponse> responses = new LinkedList<>();
166+
for (int i = 0; i < LOAD_SIZE; i += 5) {
167+
List<ListenableFuture<ListStoresResponse>> futures = new LinkedList<>();
168+
for (int j = 0; j < BURST_SIZE; j++) {
169+
futures.add(asyncCoreService.listStores(request));
170+
}
171+
172+
responses.addAll(Futures.allAsList(futures).get());
173+
}
174+
// Wait required to ensure audit logs are flushed into test audit log appender
175+
Thread.sleep(1000);
176+
177+
// Pull message audit logs from test log appender
178+
List<JsonObject> logJsonObjects =
179+
parseMessageJsonLogObjects(testAuditLogAppender.getLogs(), "ListStores");
180+
assertEquals(responses.size(), logJsonObjects.size());
181+
182+
// Extract & Check that request/response are returned correctly
183+
JsonFormat.Parser protoJSONParser = JsonFormat.parser();
184+
Streams.zip(
185+
responses.stream(),
186+
logJsonObjects.stream(),
187+
(response, logObj) -> Pair.of(response, logObj))
188+
.forEach(
189+
responseLogJsonPair -> {
190+
ListStoresResponse response = responseLogJsonPair.getLeft();
191+
JsonObject logObj = responseLogJsonPair.getRight();
192+
193+
ListStoresRequest.Builder gotRequest = null;
194+
ListStoresResponse.Builder gotResponse = null;
195+
try {
196+
String requestJson = logObj.getAsJsonObject("request").toString();
197+
gotRequest = ListStoresRequest.newBuilder();
198+
protoJSONParser.merge(requestJson, gotRequest);
199+
200+
String responseJson = logObj.getAsJsonObject("response").toString();
201+
gotResponse = ListStoresResponse.newBuilder();
202+
protoJSONParser.merge(responseJson, gotResponse);
203+
} catch (InvalidProtocolBufferException e) {
204+
throw new RuntimeException(e);
205+
}
206+
207+
assertThat(gotRequest.build(), equalTo(request));
208+
assertThat(gotResponse.build(), equalTo(response));
209+
});
210+
}
211+
212+
/**
213+
* Filter and Parse out Message Audit Logs from the given logsStrings for the given method name
214+
*/
215+
private List<JsonObject> parseMessageJsonLogObjects(List<String> logsStrings, String methodName) {
216+
JsonParser jsonParser = new JsonParser();
217+
// copy to prevent concurrent modification.
218+
return logsStrings.stream()
219+
.map(logJSON -> jsonParser.parse(logJSON).getAsJsonObject())
220+
// Filter to only include message audit logs
221+
.filter(
222+
logObj ->
223+
logObj
224+
.getAsJsonPrimitive("kind")
225+
.getAsString()
226+
.equals(AuditLogEntryKind.MESSAGE.toString())
227+
// filter by method name to ensure logs from other tests do not interfere with
228+
// test
229+
&& logObj.get("method").getAsString().equals(methodName))
230+
.collect(Collectors.toList());
231+
}
232+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
* Copyright 2018-2020 The Feast Authors
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+
* https://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+
package feast.core.logging;
18+
19+
import java.io.Serializable;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import lombok.Getter;
23+
import org.apache.logging.log4j.core.Appender;
24+
import org.apache.logging.log4j.core.Core;
25+
import org.apache.logging.log4j.core.Filter;
26+
import org.apache.logging.log4j.core.Layout;
27+
import org.apache.logging.log4j.core.LogEvent;
28+
import org.apache.logging.log4j.core.appender.AbstractAppender;
29+
import org.apache.logging.log4j.core.config.Property;
30+
import org.apache.logging.log4j.core.config.plugins.Plugin;
31+
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
32+
import org.apache.logging.log4j.core.config.plugins.PluginElement;
33+
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
34+
import org.apache.logging.log4j.core.layout.PatternLayout;
35+
36+
/** Test Log Appender used for collecting logs for testing logging. */
37+
@Plugin(
38+
name = "TestLogAppender",
39+
category = Core.CATEGORY_NAME,
40+
elementType = Appender.ELEMENT_TYPE)
41+
@Getter
42+
public class TestLogAppender extends AbstractAppender {
43+
private List<String> logs;
44+
45+
protected TestLogAppender(String name, Filter filter, Layout<? extends Serializable> layout) {
46+
super(name, filter, layout, false, new Property[] {});
47+
logs = new ArrayList<>();
48+
}
49+
50+
@Override
51+
public void append(LogEvent event) {
52+
getLogs().add(event.getMessage().toString());
53+
}
54+
55+
@PluginFactory
56+
public static TestLogAppender createAppender(
57+
@PluginAttribute("name") String name,
58+
@PluginElement("Layout") Layout<? extends Serializable> layout,
59+
@PluginElement("Filter") final Filter filter) {
60+
if (name == null) {
61+
return null;
62+
}
63+
if (layout == null) {
64+
layout = PatternLayout.createDefaultLayout();
65+
}
66+
return new TestLogAppender(name, filter, layout);
67+
}
68+
}

core/src/test/resources/log4j2.xml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
~ Copyright 2018 The Feast Authors
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+
~ https://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+
19+
<Configuration status="WARN" packages="feast.core.logging">
20+
<Properties>
21+
<Property name="LOG_PATTERN">
22+
%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex
23+
</Property>
24+
<Property name="JSON_LOG_PATTERN">
25+
{"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex
26+
</Property>
27+
</Properties>
28+
<Appenders>
29+
<Console name="ConsoleAppender" target="SYSTEM_OUT" follow="true">
30+
<MarkerFilter marker="AUDIT_MARK" onMatch="DENY" onMismatch="ACCEPT"/>
31+
<PatternLayout pattern="${LOG_PATTERN}"/>
32+
</Console>
33+
<Console name="JSONAppender" target="SYSTEM_OUT" follow="true">
34+
<MarkerFilter marker="AUDIT_MARK" onMatch="ACCEPT" onMismatch="DENY"/>
35+
<PatternLayout pattern="${JSON_LOG_PATTERN}"/>
36+
</Console>
37+
<TestLogAppender name="TestAuditLogAppender">
38+
<MarkerFilter marker="AUDIT_MARK" onMatch="ACCEPT" onMismatch="DENY"/>
39+
</TestLogAppender>
40+
</Appenders>
41+
<Loggers>
42+
<Logger name="feast.core" level="info" additivity="false">
43+
<AppenderRef ref="ConsoleAppender"/>
44+
<AppenderRef ref="JSONAppender"/>
45+
<AppenderRef ref="TestAuditLogAppender"/>
46+
</Logger>
47+
<Root level="info">
48+
<AppenderRef ref="ConsoleAppender"/>
49+
<AppenderRef ref="JSONAppender"/>
50+
<AppenderRef ref="TestAuditLogAppender"/>
51+
</Root>
52+
</Loggers>
53+
</Configuration>

0 commit comments

Comments
 (0)