In SAP Commerce, frontend applications often retrieve the latest data by periodically requesting updates from the server. For instance, this could involve checking for new notifications every few seconds or refreshing a page every 30 seconds to display the most recent content. While straightforward to implement, this approach has notable drawbacks, including increased server load, unnecessary network traffic, and a suboptimal user experience due to update delays.
Luckily, Server-Sent Events (SSE) offer a more efficient alternative. With SSE, the backend can push updates to the frontend in real-time, eliminating the need for polling. This improves performance and provides users with a more seamless and up-to-date experience.
Problem Description
Most tutorials on server-sent events (SSE) focus on the basics: setting up a simple Spring MVC controller, creating a streaming endpoint, and pushing data to the frontend. These examples are fine for toy projects but fall short in real-world scenarios.
Enterprise-grade projects like those built on SAP Commerce demand much more. Real-world scenarios involve integrating SSE into large, modular codebases, handling high concurrency, ensuring secure authorization, and adhering to strict performance requirements. These challenges are rarely covered in tutorials, which don’t even mention that SseEmitter.SseEventBuilder
implementation is not thread-safe.
Additionally, SAP Commerce introduces its own set of constraints. For SSE to function properly in Spring MVC, async request processing must be enabled in the servlet. This is typically done by adjusting the web.xml
configuration. However, in SAP Commerce’s OOTB setup, async support is not enabled in the commercewebservices
extension’s servlet definitions and can’t be adjusted as it is an OOTB read only file, what prevents direct adoption of this approach.
Technical Solution
In the nutshell Server-Sent Events (SSE) is long-living GET request, into which BE writes data(events or messages) in special format. Technical details can be found at HTML Server-Sent Events Standard. As a basis of implementation would be used SseEmitter, provided by Spring MVC framework, which is a kind of wrapper for long-lived Request/Response, which allows to write data in Response at any time, so it is transmitted to FE in real-time. Instances of SseEmitter
must be stored in application memory, as they represent Response to FE application (holds connection to FE client), in which backend must write data.
New asyncwebservices
web extension should be introduced to create a Servlet with async support and all SSE related implementation. To decouple SSE implementation from other code base and ensure that other extensions, would not depend on asyncwebservices
extensions, would be used Spring Event system as an intermediate message bus instead of direct invocation of SSE service methods. SseCompatibleSpringEvent
interface should be introduced in core extension and used in asyncwebservices
extension to be able to differentiate between regular events and events, which should be sent to FE as SSE.

The implementation consists of several key components. The ServerSentEventsController
acts as the entry point for the frontend, exposing an endpoint where clients can establish SSE connections. It delegates the creation and management of SseEmitter
instances to SseEmitterService
, which is responsible for registering new subscribers and sending events.
To efficiently store and manage active connections, the SseEmitterStorage
component is introduced, mapping each user to their active SseEmitter
instances. SseStorageResolver
is used to separate anonymous users from logged-in users to ensure that events would be delivered to proper users. Meanwhile, domain events that need to be published as SSE messages are captured by the SseCompatibleEventListener
, which listens for relevant Spring Events, converts them to SseEvent
, and forwards them using SseEventSendService
.
Implementation Details
SseCompatibleSpringEvent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
package com.blog.core.sse;
import de.hybris.platform.servicelayer.event.ClusterAwareEvent;
import java.io.Serializable;
import java.util.List;
public interface SseCompatibleSpringEvent extends ClusterAwareEvent {
enum SsePublishType {BROADCAST, EVERYONE, ID}
enum SseSubscriberType {ANONYMOUS, AUTHENTICATED}
record SsePublishOptions(
SsePublishType publishType,
SseSubscriberType subscriberType,
List<String> ids
) implements Serializable {
}
default Object getSseData() {
return null;
}
default SsePublishOptions getSeePublishOptions() {
return new SsePublishOptions(SsePublishType.ID, SseSubscriberType.AUTHENTICATED, List.of());
}
}
|
The SseCompatibleSpringEvent
interface defines a standard structure for events that should be transmitted via SSE. It includes options to specify whether an event should be broadcasted to all users or targeted to specific users(anonymous or logged-in). This interface is used inside service layer to mark regular Spring Event, as event, which should be sent to FE via SSE. To not introduce circular dependencies between extensions, this interface should be a part of core extension.
SseCompatibleSpringEvent
interface extends ClusterAwareEvent
, which is required to properly work in multi-node environments to support cases, when SSE connection was made on one node and spring event with BE data update is triggered from another node (for example, background processing).
New asyncwebservices extension
First of all should be created a new asyncwebservices
extension from ywebservices
template. And web.xml
should be adjusted to include <async-supported>true</async-supported>
in all filters and servlet definitions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
<?xml version="1.0" encoding="iso-8859-1"?>
<!--
Copyright (c) 2020 SAP SE or an SAP affiliate company. All rights reserved.
-->
<web-app id="asyncwebservices" version="3.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
metadata-complete="true">
<absolute-ordering/>
<display-name>asyncwebservices</display-name>
<filter>
<filter-name>XSSFilter</filter-name>
<filter-class>de.hybris.platform.servicelayer.web.XSSFilter</filter-class>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>XSSFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>characterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<async-supported>true</async-supported>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter>
<filter-name>SessionHidingRequestFilter</filter-name>
<filter-class>de.hybris.platform.webservicescommons.filter.SessionHidingFilter</filter-class>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>SessionHidingRequestFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>asyncwebservicesPlatformFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>characterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>asyncwebservicesPlatformFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
<servlet-name>springmvc</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
<servlet-name>springmvc</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>WEB-INF/asyncwebservices-web-spring.xml</param-value>
</context-param>
<context-param>
<param-name>contextInitializerClasses</param-name>
<param-value>
de.hybris.platform.webservicescommons.initializer.HybrisPropertiesWebApplicationContextInitializer
</param-value>
</context-param>
<listener>
<listener-class>de.hybris.platform.spring.HybrisContextLoaderListener</listener-class>
</listener>
<listener>
<listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
</listener>
</web-app>
|
This is required step, otherwise you would hit java.lang.IllegalStateException: Async support must be enabled on a servlet
.
To enable swagger documentation <requires-extension name="swaggerintegration"/>
must be added into extensioninfo.xml
. After that documentation would be available on https://localhost:9002/asyncwebservices/swagger-ui/index.html
ServerSentEventsController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
package com.blog.async.controllers.sse;
import com.blog.async.controllers.BaseController;
import com.blog.async.sse.services.SseEmitterService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Validator;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Resource;
@Controller
@Tag(name = "SSE")
public class ServerSentEventsController extends BaseController {
private static final Logger LOG = LoggerFactory.getLogger(ServerSentEventsController.class);
@Resource
private Validator sseClientIdValidator;
@Resource
private SseEmitterService sseEmitterService;
@GetMapping(path = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(
summary = "Subscribe on SSE",
description = "Subscribes on Server-Sent Events for anonymous and logged in users."
)
public SseEmitter subscribe(
@Parameter(description = "Optional parameter for logged in users, but it is mandatory for anonymous users.")
@RequestParam(required = false, defaultValue = "")
String cid
) {
validate(cid, "clientId", sseClientIdValidator);
SseEmitter emitter = sseEmitterService.create(cid);
if (emitter == null) {
throw new IllegalArgumentException("Server-Sent Events subscription failed.");
}
return emitter;
}
@ExceptionHandler
public void exceptionHandler(AsyncRequestTimeoutException e) {
// ! On request timeout we want close it. FE would automatically reconnect according to specification.
if (LOG.isDebugEnabled()) {
LOG.debug("Exception:", e);
}
}
}
|
ServerSentEventsController
is main entrypoint for FE to subscribe on push messages from BE, which uses SseEmitterService
to create instances of SseEmitter
. It supports both anonymous users and logged-in users. To support sending messages for particular anonymous users FE should provide an uniquer identifier, which can be randomly generated and stored in browser session storage. Or it can be a unique identifier of process, which is triggered by anonymous user. For example, anonymous user can trigger PDF generation cronjob, and create SSE connection with cronjob id, so when cronjob would finish it can send message to FE with link for downloading PDF file.
In Appendix you can find an example implementation of sseClientIdValidator
, which ensures that anonymous users always send clientId parameter, while logged-in users never send it.
SseEmitterService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
package com.blog.async.sse.services.impl;
import com.blog.async.sse.converter.SseEmitterInitEventConverter;
import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEmitterService;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.user.UserService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Nullable;
import javax.annotation.Resource;
import java.util.List;
public class DefaultSseEmitterService implements SseEmitterService {
private static final Logger LOG = LoggerFactory.getLogger(DefaultSseEmitterService.class);
@Resource
private UserService userService;
@Resource
private SseStorageResolver sseStorageResolver;
@Resource
private SseEventSendService sseEventSendService;
@Autowired(required = false)
private List<SseEmitterInitEventConverter> sseEmitterInitEventConverters;
@Nullable
@Override
public SseEmitter create(@Nullable String id) {
UserModel currentUser = userService.getCurrentUser();
SseEmitter emitter = createEmitter(currentUser, id);
publishInitialEvents(emitter, currentUser, id);
return emitter;
}
private SseEmitter createEmitter(UserModel currentUser, String id) {
SseCompatibleSpringEvent.SseSubscriberType subscriberType = resolveSubscriberType(currentUser);
if (subscriberType == SseCompatibleSpringEvent.SseSubscriberType.ANONYMOUS && StringUtils.isBlank(id)) {
LOG.warn("ID must be provided for anonymous user to create SseEmitter.");
return null;
}
SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(subscriberType);
return sseEmitterStorage.create(createEmitterId(subscriberType, currentUser, id));
}
private void publishInitialEvents(SseEmitter emitter, UserModel currentUser, String id) {
if (emitter == null || sseEmitterInitEventConverters == null) {
return;
}
for (SseEmitterInitEventConverter initEventResolver : sseEmitterInitEventConverters) {
SseEvent event = initEventResolver.convert(currentUser, id);
if (event != null) {
sseEventSendService.sendEvent(emitter, event);
}
}
}
private SseCompatibleSpringEvent.SseSubscriberType resolveSubscriberType(UserModel user) {
if (userService.isAnonymousUser(user)) {
return SseCompatibleSpringEvent.SseSubscriberType.ANONYMOUS;
}
return SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED;
}
private String createEmitterId(SseCompatibleSpringEvent.SseSubscriberType subscriberType, UserModel user, String id) {
if (subscriberType == SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED) {
return user.getUid();
}
return id;
}
}
|
SseEmitterService
is used to create instance of SseEmitter
and register them in SseEmitterStorage
. On creation ServerSentEventService#create
emitter is automatically registered in appropriate SseEmitterStorage
and initial events/messages are send to FE right after creation using SseEventSendService
. Initial events/messages are retrieved from List<SseEmitterInitEventConverter>
.
SseStorageResolver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
package com.blog.async.sse.storage.impl;
import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.sse.SseCompatibleSpringEvent;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
public class DefaultSseStorageResolver implements SseStorageResolver {
@Resource
private SseEmitterStorage authenticatedUserMapSseEmitterStorage;
@Resource
private SseEmitterStorage anonymousUserMapSseEmitterStorage;
@Override
public Collection<SseEmitterStorage> getAllStorages() {
return List.of(anonymousUserMapSseEmitterStorage, authenticatedUserMapSseEmitterStorage);
}
@Override
public SseEmitterStorage resolveStorage(SseCompatibleSpringEvent.SseSubscriberType sseSubscriberType) {
if (sseSubscriberType == SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED) {
return authenticatedUserMapSseEmitterStorage;
}
return anonymousUserMapSseEmitterStorage;
}
}
|
SseStorageResolver
is used to physically separate logged-in and anonymous users, together with SseClientIdValidator it allows to ensure that anonymous users would not be able to crack clientId and subscribe on events of logged-in users to receive some user sensitive information.
SseEmitterStorage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
package com.blog.async.sse.storage.impl;
import com.blog.async.sse.storage.SseEmitterStorage;
import org.apache.commons.lang.StringUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
public class ConcurrentMapSseEmitterStorage implements SseEmitterStorage {
private final Map<String, Queue<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
@Nullable
@Override
public SseEmitter create(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
SseEmitter emitter = createEmitterInstance(id);
Queue<SseEmitter> emitterQueue = emitterMap.computeIfAbsent(id, k -> new ConcurrentLinkedQueue<>());
emitterQueue.add(emitter);
return emitter;
}
@NotNull
@Override
public Collection<SseEmitter> getAll() {
return emitterMap.values().stream()
.flatMap(Collection::stream)
.toList();
}
@NotNull
@Override
public Collection<SseEmitter> get(String id) {
Queue<SseEmitter> emitters = emitterMap.get(id);
if (emitters == null) {
return List.of();
}
return List.copyOf(emitters);
}
private void removeEmitter(String key, SseEmitter emitter) {
Queue<SseEmitter> userEmitters = emitterMap.get(key);
if (userEmitters == null) {
return;
}
userEmitters.remove(emitter);
if (userEmitters.isEmpty()) {
emitterMap.remove(key);
}
}
private SseEmitter createEmitterInstance(String key) {
// ! 5 minute is good enough timeout for initial implementation
// ! based on current amount of users and RAM consumption.
// ! Can be adjusted if needed.
long timeoutInMillis = TimeUnit.MINUTES.toMillis(5);
SseEmitter emitter = new SseEmitter(timeoutInMillis);
emitter.onCompletion(() -> removeEmitter(key, emitter));
emitter.onTimeout(() -> removeEmitter(key, emitter));
emitter.onError((e) -> removeEmitter(key, emitter));
return emitter;
}
}
|
SseEmitter
is registered in SseEmitterStorage
per user or clientId. And in case there is already registered SseEmitter
, new SseEmitter
should be created and initial events/messages should be resent. This is needed to support login for same user from multiple browsers/tabs/devices.
SseEmitter
are created with 5 minute timeout, what would lead to automatic close of connection from server side after timeout time. That allows to free resources on server side after FE client disconnect.
SseEmitterStorage
is used to store instances of SseEmitter
with possibility to get SseEmitter
for particular user, same as receive all registered SseEmitter
for broadcast type of events. ConcurrentMapSseEmitterStorage
implementation utilizes a ConcurrentHashMap
to store ConcurrentLinkedQueue
of SseEmitter
per user, which allows to deal with multiple concurrent and async requests without needs for explicit synchronization.
SseEventSendService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
package com.blog.async.sse.services.impl;
import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.concurrent.ThreadExecutorHelper;
import com.blog.core.concurrent.ThreadPoolType;
import com.blog.core.sse.SseCompatibleSpringEvent;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Resource;
import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.util.Collection;
public class DefaultSseEventSendService implements SseEventSendService {
@Resource
private ThreadExecutorHelper threadExecutorHelper;
@Resource
private SseStorageResolver sseStorageResolver;
@Override
public void sendEvent(SseEmitter emitter, @NotNull SseEvent sseEvent) {
sendEventAsync(emitter, sseEvent);
}
@Override
public void sendEvent(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, @NotNull SseEvent sseEvent) {
switch (ssePublishOptions.publishType()) {
case ID -> sendEventsToSpecifiedEmitters(ssePublishOptions, sseEvent);
case BROADCAST -> sendEventToSpecifiedSubscriberTypes(ssePublishOptions, sseEvent);
case EVERYONE -> sendEventsToEveryone(sseEvent);
}
}
private void sendEventsToSpecifiedEmitters(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, SseEvent sseEvent) {
SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(ssePublishOptions.subscriberType());
ssePublishOptions.ids().parallelStream()
.flatMap(id -> sseEmitterStorage.get(id).parallelStream())
.forEach(emitter -> sendEventAsync(emitter, sseEvent));
}
private void sendEventToSpecifiedSubscriberTypes(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, SseEvent sseEvent) {
SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(ssePublishOptions.subscriberType());
sseEmitterStorage.getAll().parallelStream().forEach(emitter -> sendEventAsync(emitter, sseEvent));
}
private void sendEventsToEveryone(SseEvent sseEvent) {
Collection<SseEmitterStorage> storages = sseStorageResolver.getAllStorages();
storages.parallelStream()
.flatMap(storage -> storage.getAll().parallelStream())
.forEach(emitter -> sendEventAsync(emitter, sseEvent));
}
private void sendEventAsync(SseEmitter emitter, SseEvent sseEvent) {
threadExecutorHelper.executeAsync(ThreadPoolType.SSE, () -> safeSend(emitter, sseEvent));
}
private static void safeSend(SseEmitter emitter, SseEvent sseEvent) {
try {
// ! [[SseEventBuilder]] is not thread safe, so we need to create a new instance inside each thread.
emitter.send(createEventBuilder(sseEvent));
} catch (IllegalStateException e) {
emitter.completeWithError(e);
} catch (IOException e) {
// Ignore as it would be handled by Spring MVC itself with [[AsyncRequestTimeoutException]].
}
}
private static SseEmitter.SseEventBuilder createEventBuilder(SseEvent sseEvent) {
return SseEmitter.event()
.name(sseEvent.name())
.data(sseEvent.data());
}
}
|
SseEventSendService#sendEvent
methods are used to send events/messages to FE (via SseEmitter
). Both methods receive SseEvent
as parameter and utilize separate thread pool for async execution, which is usually needed to not block execution of other requests or service layer methods, as message broadcast can be a time-consuming procedure. parallelStream
is utilized to get additional performance improvement, which can be safely used as SseEmitter
doesn’t need SAP Commerce session.
SseEvent
interface defines methods name
and data
and is used to represent SSE objects in domain/service logic. SimpleSseEvent
is main record
implementation. Reference implementation can be found in Appendix
Implementation of async thread pool and ThreadExecutorHelper
can be found in article ThreadPool for async non-blocking execution. In some cases, like CronJobs, it could be acceptable to block BE execution threads, so event can be sent directly without async thread pool.
SseCompatibleEventListener
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
package com.blog.async.sse.event.listeners;
import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.servicelayer.event.events.AbstractEvent;
import de.hybris.platform.servicelayer.event.impl.AbstractEventListener;
import org.springframework.core.convert.converter.Converter;
import javax.annotation.Resource;
import java.util.Map;
public class SseCompatibleEventListener extends AbstractEventListener<AbstractEvent> {
@Resource
private Converter<SseCompatibleSpringEvent, ? extends SseEvent> defaultSseEventConverter;
@Resource
private Map<Class<? extends AbstractEvent>, Converter<SseCompatibleSpringEvent, ? extends SseEvent>> sseCompatibleSpringEventConverters;
@Resource
private SseEventSendService sseEventSendService;
@Override
protected void onEvent(AbstractEvent abstractEvent) {
if (!(abstractEvent instanceof SseCompatibleSpringEvent sseCompatibleSpringEvent)) {
return;
}
SseEvent sseEvent = convert(sseCompatibleSpringEvent);
sseEventSendService.sendEvent(sseCompatibleSpringEvent.getSeePublishOptions(), sseEvent);
}
private SseEvent convert(SseCompatibleSpringEvent sseCompatibleSpringEvent) {
Converter<SseCompatibleSpringEvent, ? extends SseEvent> converter = sseCompatibleSpringEventConverters.get(
sseCompatibleSpringEvent.getClass()
);
if (converter != null) {
return converter.convert(sseCompatibleSpringEvent);
}
return defaultSseEventConverter.convert(sseCompatibleSpringEvent);
}
}
|
To decouple service layer data changes from sending events to FE is used SseCompatibleEventListener
, which is regular Spring Event Listener. Implementation filters out Spring Events, which does not implement SseCompatibleSpringEvent
interface, converts them into SseEvent
, identifies if event should be broadcast or sent to specific user and uses ServerSentEventService
for that.
SseCompatibleSpringEvent
interface defines default empty implementation for getSseData
and getSeePublishOptions
methods, which are used to pass additional data and identify if event should be broadcasted or sent to specific users.
SseCompatibleEventListener
uses Map
to resolve Converter<SseCompatibleSpringEvent, SseEvent>
for current Spring Event, if it is not found DefaultSseEventConverter
implementation is used as a fallback. DefaultSseEventConverter
converts SseCompatibleSpringEvent
into SseEvent
using Spring Event class simple name as SSE event name and string representation of SseCompatibleSpringEvent#getSseData
as SSE event data.
Convertion of Spring Event implementation into SseEvent
would contain logic of initial event creation, so there is a sense to have same class implementing SseEmitterInitEventConverter
and Converter<AbstractEvent, SseEvent>
interfaces, instead of having two separate converters with common logic extracted in Util. This is application of Interface Segregation Principle and Cohesive Design.
Example implementation
Above code provides an infrastructure code inside SAP Commerce to support SSE. Below is provided example on how to use that infrastructure code for real business case. Let’s imagine that we want to notify FE that underlying ERP service is not currently available to show user on all pages a message that some functionality is not available right now, or that prices are not final etc.
BE implementation
First of all we need to create implementation of SseCompatibleSpringEvent
interface for triggering spring evens and ErpStatusEventData
to hold data about current state of ERP.
SseCompatibleSpringEvent implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
package com.blog.core.event;
import com.blog.core.data.ErpStatusEventData;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.servicelayer.event.events.AbstractEvent;
public class ErpStatusUpdateEvent extends AbstractEvent implements SseCompatibleSpringEvent {
private final ErpStatusEventData erpStatusEventData;
public ErpStatusUpdateEvent(boolean isOffline, long offlineStartTime) {
this.erpStatusEventData = new ErpStatusEventData(isOffline, offlineStartTime);
}
@Override
public Object getSseData() {
return erpStatusEventData;
}
}
|
1
2
3
4
5
6
7
|
package com.blog.core.data;
import java.io.Serializable;
public record ErpStatusEventData(boolean isOffline, long offlineStartTime) implements Serializable {
}
|
SseEvent converter and SseEmitterInitEventConverter
Next step would be to create converter from ErpStatusUpdateEvent
to SseEvent
, which would also implement a SseEmitterInitEventConverter
, so on SSE connection establishing FE would receive current state of ERP. Event data is converted into JSON, so it is easier for FE consumption.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
package com.blog.async.sse.converter.impl;
import com.blog.async.sse.converter.SseEmitterInitEventConverter;
import com.blog.async.sse.domain.SimpleSseEvent;
import com.blog.async.sse.domain.SseEvent;
import com.blog.core.data.ErpStatusEventData;
import com.blog.core.erp.ErpStatusService;
import com.blog.core.event.ErpStatusUpdateEvent;
import com.blog.core.json.BlogJsonService;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.user.UserService;
import org.springframework.core.convert.converter.Converter;
import javax.annotation.Nullable;
import javax.annotation.Resource;
import javax.validation.constraints.NotNull;
public class ErpStatusUpdateEventConverter implements Converter<ErpStatusUpdateEvent, SseEvent>, SseEmitterInitEventConverter {
private enum ErpStatusEventType {
ONLINE, OFFLINE
}
@Resource
private ErpStatusService erpStatusService;
@Resource
private UserService userService;
@Resource
private BlogJsonService blogJsonService;
private record ErpStatusUpdateEventData(String status, long offlineStartTime) {
}
@Nullable
@Override
public SseEvent convert(@NotNull ErpStatusUpdateEvent source) {
return internalConvert(source);
}
@Override
public SseEvent convert(@Nullable UserModel user, String emitterId) {
if (userService.isAnonymousUser(user)) {
return null;
}
return internalConvert(null);
}
private SimpleSseEvent internalConvert(ErpStatusUpdateEvent source) {
return new SimpleSseEvent(
"calculation_services_state_update",
blogJsonService.toJson(resolveEventData(source))
);
}
private ErpStatusUpdateEventData resolveEventData(ErpStatusUpdateEvent springEvent) {
if (springEvent == null) {
return resolveInitialEventData();
}
return resoleEventDataFromSpringEvent(springEvent);
}
private static ErpStatusUpdateEventData resoleEventDataFromSpringEvent(ErpStatusUpdateEvent springEvent) {
Object sseData = springEvent.getSseData();
if (sseData instanceof ErpStatusEventData erpStatusEventData) {
return createEventData(
getErpStatusEventType(erpStatusEventData.isOffline())
.name(),
erpStatusEventData.offlineStartTime()
);
}
return null;
}
private ErpStatusUpdateEventData resolveInitialEventData() {
return createEventData(
getErpStatusEventType(erpStatusService.isOffline())
.name(),
erpStatusService.offlineStartTime()
);
}
private static ErpStatusUpdateEventData createEventData(String status, long offlineStartTime) {
return new ErpStatusUpdateEventData(
status,
offlineStartTime
);
}
private static ErpStatusEventType getErpStatusEventType(boolean isOffline) {
return isOffline ? ErpStatusEventType.OFFLINE : ErpStatusEventType.ONLINE;
}
}
|
1
2
3
4
5
|
<bean id="erpStatusUpdateEventConverter" class="com.blog.async.sse.converter.impl.ErpStatusUpdateEventConverter"/>
<util:map id="sseCompatibleSpringEventConverters" key-type="java.lang.Class">
<entry key="com.blog.core.event.ErpStatusUpdateEvent" value-ref="erpStatusUpdateEventConverter"/>
</util:map>
|
Publish SseCompatibleSpringEvent
Now we can simply publish regular Spring Event with de.hybris.platform.servicelayer.event.EventService
and receive that information on FE:
1
|
eventService.publishEvent(new ErpStatusUpdateEvent(isOffline, offlineStartTime));
|
1
|
calculation_services_state_update: {"status": "ONLINE", "offlineStartTime" : "1742635632" }
|
Add corsFilter
into asyncwebservices-web-spring.xml
. It has same setup as OCC endpoints, reusing OCC properties:
1
2
3
|
corsfilter.asyncwebservices.allowedOrigins = ${corsfilter.commercewebservices.allowedOrigins}
corsfilter.asyncwebservices.allowedMethods = ${corsfilter.commercewebservices.allowedMethods}
corsfilter.asyncwebservices.allowedHeaders = ${corsfilter.commercewebservices.allowedHeaders}
|
FE implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
let unsuccessfulConnectCount = 0;
const reconnectIntervals = [1, 2, 5, 10, 20];
let eventSource;
const createOrReconnectEventSource = (url) => {
const connect = () => {
eventSource = new EventSource(url);
eventSource.onopen = () => {
unsuccessfulConnectCount = 0;
};
eventSource.onerror = () => {
unsuccessfulConnectCount++;
if (reconnectIntervals[unsuccessfulConnectCount]) {
setTimeout(connect, reconnectIntervals[unsuccessfulConnectCount] * 1000);
} else {
console.error("Connection failed after multiple attempts.");
}
};
};
if (eventSource && eventSource.readyState === EventSource.OPEN) {
return eventSource;
}
if (eventSource) {
eventSource.close();
}
connect();
return eventSource;
};
const addEventSubscription = (eventName, callback) => {
if (eventSource) {
eventSource.addEventListener(eventName, callback);
} else {
console.error("EventSource not created");
}
};
const url = `/asyncwebservices/sse?access_token=${getCookie("access_token")}`;
const eventSourceInstance = createOrReconnectEventSource(url);
addEventSubscription("custom_event", (e) => console.log(e.data));
addEventSubscription("another_event", (e) => console.log(e.data));
|
Above implementation uses browser default EventSource
to work with SSE.
Appendix
SseClientIdValidator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
package com.blog.async.validator;
import de.hybris.platform.servicelayer.user.UserService;
import org.apache.commons.lang.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import javax.annotation.Resource;
public class SseClientIdValidator implements Validator {
@Resource
private UserService userService;
@Override
public boolean supports(Class<?> clazz) {
return String.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
String clientId = (String) target;
if (userService.isAnonymousUser(userService.getCurrentUser())) {
if (StringUtils.isBlank(clientId)) {
errors.reject("Anonymous users must provide Client ID for SSE.");
}
} else {
if (StringUtils.isNotEmpty(clientId)) {
errors.reject("Authenticated users must not provide Client ID for SSE.");
}
}
}
}
|
SseEvent and SimpleSseEvent
1
2
3
4
5
6
7
8
9
|
package com.blog.async.sse.domain;
public interface SseEvent {
String name();
String data();
}
|
1
2
3
4
5
|
package com.blog.async.sse.domain;
public record SimpleSseEvent(String name, String data) implements SseEvent {
}
|
Production CDNs
Some of CDN providers, like Akamai, can buffer/batch server responses, while using SSE. And it is required to work with their support to enable realtime SSE on production. Here is a link on stackoverflow with changes required to be done on Akamai side.
IllegalStateException: Async support must be enabled on a servlet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
|
[2025/03/16 13:19:23.738] ERROR [hybrisHTTP39] [] [AbstractRestHandlerExceptionResolver] java.lang.IllegalStateException: Async support must be enabled on a servlet and for all filters involved in async request processing. This is done in Java code using the Servlet API or by adding "<async-supported>true</async-supported>" to servlet and filter declarations in web.xml.
at org.springframework.util.Assert.state(Assert.java:76)
at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.startAsync(StandardServletAsyncWebRequest.java:141)
at org.springframework.web.context.request.async.WebAsyncManager.startAsyncProcessing(WebAsyncManager.java:517)
at org.springframework.web.context.request.async.WebAsyncManager.startDeferredResultProcessing(WebAsyncManager.java:492)
at org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler.handleReturnValue(ResponseBodyEmitterReturnValueHandler.java:177)
at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:135)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:903)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:809)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:529)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:623)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:339)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$StatisticsGatewayFilter.doFilter(AbstractPlatformFilterChain.java:427)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:352)
at de.hybris.platform.webservicescommons.oauth2.HybrisOauth2UserFilter.doFilter(HybrisOauth2UserFilter.java:47)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:117)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:83)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:131)
at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:85)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:164)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationProcessingFilter.doFilter(OAuth2AuthenticationProcessingFilter.java:182)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90)
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:117)
at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:87)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.access.channel.ChannelProcessingFilter.doFilter(ChannelProcessingFilter.java:133)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:225)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:190)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at de.hybris.platform.webservicescommons.filter.RestSessionFilter.doFilter(RestSessionFilter.java:39)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at de.hybris.platform.servicelayer.web.TenantActivationFilter.doFilter(TenantActivationFilter.java:76)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at de.hybris.platform.servicelayer.web.Log4JFilter.doFilter(Log4JFilter.java:37)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain.processStandardFilterChain(AbstractPlatformFilterChain.java:217)
at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain.doFilterInternal(AbstractPlatformFilterChain.java:194)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at de.hybris.platform.webservicescommons.filter.SessionHidingFilter.doFilter(SessionHidingFilter.java:34)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at de.hybris.platform.servicelayer.web.XSSFilter.processPatternsAndDoFilter(XSSFilter.java:351)
at de.hybris.platform.servicelayer.web.XSSFilter.doFilter(XSSFilter.java:299)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:168)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:481)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:670)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:346)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:390)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:928)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1794)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
at java.base/java.lang.Thread.run(Thread.java:840)
|