Custom Spring bean (EventServer)
When you need to reach the lsFusion system from your own Java code — to run a background job, host an integration server, process a stream of events, expose an RMI service — that is done with a Spring bean extending the EventServer hierarchy.
This is the second of the two ways of accessing lsFusion from an internal system, parallel to the InternalAction path.
Base class
The bean extends one of two classes:
MonitorServer— for most components: schedulers, socket listeners, message-queue consumers, periodic jobs.RmiServer— when the bean must be reachable by external clients as an RMI service.
The minimum Java overrides are getEventName(), getLogicsInstance(), and the relevant lifecycle hooks.
getEventName() must return a constant (typically a string literal). It is called from the field initialiser of EventServer (new TopExecutionStack(getEventName())) before the subclass's own field initialisers and constructor body run, so it cannot rely on DI-injected values or subclass fields — at that moment they have not been assigned yet.
Threads and stack
Each base class establishes its own thread context: MonitorServer — monitor, RmiServer — RMI. How to obtain an ExecutionStack depends on the kind of thread the code is running on:
-
Own task pool. Built through
ExecutorFactory.createMonitorThreadService(threads, this)orcreateMonitorScheduledThreadService(threads, this)for aMonitorServerbean;createRMIThreadService(threads, this)for aRmiServer. Threads in such a pool enter and leave the appropriate thread context automatically;getStack()works inside the tasks. -
External callback thread (worker threads of a RabbitMQ client, WebSocket library, etc. — threads not created through
ExecutorFactory). The context is set up and torn down around the block manually; the code usually sits inside an anonymous class (DefaultConsumer, etc.), so theMonitorServeris referred to explicitly asMyServer.this(a barethiswould point at the anonymous instance):try {
ThreadLocalContext.aspectBeforeMonitorHTTP(MyServer.this);
try (DataSession session = createSession()) {
// getStack() works here
...
}
} finally {
ThreadLocalContext.aspectAfterMonitorHTTP(MyServer.this);
}Inside the wrapped block
getStack()is valid. -
Lifecycle method or one-off call (the thread is in neither the monitor nor the RMI context, and there is no need to wrap it). Use
getTopStack()— the top-level execution stack tied to the bean itself.
RMI export
A complete RMI-service template is a RmiServer bean that implements a remote interface, plus a manual export through RmiManager.
Remote interface extends lsfusion.interop.server.RmiServerInterface (rather than the bare java.rmi.Remote); every remote method declares throws RemoteException:
public interface MyRemoteInterface extends RmiServerInterface {
String doSomething(String arg) throws RemoteException;
}
The RmiServer bean implements this interface. The subclass itself is not exported over RMI automatically — the export is done by hand through RmiManager:
- in
onStarted:getLogicsInstance().getRmiManager().bindAndExport(name, this)— exports the object and registers it in the platform's RMI registry under the name<exportName>/<name>, whereexportNameis set in the platform'srmiManagerconfiguration andnameis a short service identifier ("EquipmentServer", etc.). - in
onStopping:getLogicsInstance().getRmiManager().unbindAndUnexport(name, this)— unbinds and unexports.
If RmiManager is used often, a getRmiManager() helper method is usually added on the bean itself (see EquipmentServer.getRmiManager() in the ERP).
The RMI context inside a remote method is normally set up for you — just call getStack() and work with the session. An explicit ThreadLocalContext.assureRmi(this) may be added as a defensive call in non-standard paths, but no manual aspect setup is required in the typical case.
createSession() throws SQLException, and the LP / LA / applyException calls throw SQLException and SQLHandledException; in a remote method these are usually caught in one block and wrapped in RemoteException:
@Override
public String doSomething(String arg) throws RemoteException {
BusinessLogics BL = getLogicsInstance().getBusinessLogics();
try (DataSession session = createSession()) {
String result = "..."; // read/write properties, execute actions — getStack() is valid
session.applyException(BL, getStack()); // required if anything was written
return result;
} catch (SQLException | SQLHandledException e) {
throw new RemoteException("doSomething failed", e);
}
}
The RMI client locates the service in the registry under the same <exportName>/<name>.
Lifecycle
The bean is injected via Spring (the minimum is logicsInstance (LogicsInstance); through it the bean reaches getBusinessLogics(), getDbManager(), getRmiManager()). The standard hooks are used as follows:
afterPropertiesSet()(when the bean implementsInitializingBean) — fires right after Spring DI; only the injection check goes here (Assert.notNull(...)). The platform runtime is not ready yet, sessions cannot be opened.onInit(LifecycleEvent)— platform initialisation has started. This is where the module is resolved (getLogicsInstance().getBusinessLogics().getModule("MyModule")) andLP/LAwrappers are stored in fields viaLM.findProperty(...)/LM.findAction(...).onStarted(LifecycleEvent)— the platform is fully up. This is where background threads and listeners are started;RmiServer-style beans callbindAndExporthere.onStopping(LifecycleEvent)— graceful shutdown: stop owned threads; forRmiServercallunbindAndUnexport.
If the component must come up after the main platform, pass super(DAEMON_ORDER) in the constructor.
Reading, writing and executing
Inside the bean's methods, properties and actions are accessed through a DataSession and an ExecutionStack (rather than an ExecutionContext, as in InternalAction). Object arguments are passed as DataObject (or, more generally, ObjectValue — either DataObject or NullValue); the written property values are plain Java values of built-in classes (String, Integer, LocalDateTime, etc.). A full catalog of classes and methods is in Java API for integrations. LP / LA wrappers are normally resolved once in onInit and stored in fields; here, for brevity, they are obtained at the call site:
BusinessLogics BL = getLogicsInstance().getBusinessLogics();
LP<?> someProperty = BL.findProperty("name[Class]");
LA<?> someAction = BL.findAction("name[Class]");
ExecutionStack stack = getStack();
DataObject keyObject = new DataObject(keyValue, (ConcreteCustomClass) BL.findClass("Class"));
try (DataSession session = createSession()) {
Object value = someProperty.read(session, keyObject);
someProperty.change(newJavaValue, session, keyObject);
someAction.execute(session, stack, keyObject);
session.applyException(BL, stack);
}
createSession() opens a new change session; whatever is written into it accumulates until applied explicitly — session.applyException(BL, stack) (throws on apply failure) or session.applyMessage(BL, stack) (returns the error message, or null on success). If an exception is thrown before apply, try-with-resources rolls back everything that was not yet applied.
Wiring up in Spring
To plug the bean in, the project module places its lsfusion-bootstrap.xml into src/main/resources (it shadows the platform file with the same name). It imports lsfusion.xml, declares the bean, and registers it in customLifecycleListeners:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:util="http://www.springframework.org/schema/util"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
default-lazy-init="true">
<import resource="classpath:lsfusion.xml"/>
<bean id="dataSyncServer" class="myapp.controller.DataSyncServer">
<property name="logicsInstance" ref="logicsInstance"/>
</bean>
<util:list id="customLifecycleListeners">
<ref bean="dataSyncServer"/>
</util:list>
</beans>
<util:list id="customLifecycleListeners"> overrides the same empty list from the platform's lsfusion.xml and lists the beans that should receive onInit / onStarted / onStopping.
If the bean must also be reachable from arbitrary code via getLogicsInstance().getCustomObject(MyClass.class), list it additionally under <util:list id="customObjects"> — the platform's service registry (a Class → Object map). In practice such beans are commonly registered in both lists at once.
Example
DataSyncServer is a periodic job that polls an external system once a minute and updates statistics in lsFusion properties. It resolves properties in onInit, starts an executor in onStarted, shuts the executor down in onStopping, and applies changes in DataSession.
package myapp.controller;
import lsfusion.server.base.controller.lifecycle.LifecycleEvent;
import lsfusion.server.base.controller.manager.MonitorServer;
import lsfusion.server.base.controller.thread.ExecutorFactory;
import lsfusion.server.language.ScriptingErrorLog;
import lsfusion.server.language.ScriptingLogicsModule;
import lsfusion.server.language.property.LP;
import lsfusion.server.logics.BusinessLogics;
import lsfusion.server.logics.LogicsInstance;
import lsfusion.server.logics.action.session.DataSession;
import lsfusion.server.physics.admin.log.ServerLoggers;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;
import java.time.LocalDateTime;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class DataSyncServer extends MonitorServer implements InitializingBean {
private static final Logger logger = ServerLoggers.startLogger;
private LogicsInstance logicsInstance;
private ScheduledExecutorService executor;
private LP<?> lastSyncedAt;
private LP<?> totalRecordCount;
public DataSyncServer() {
super(DAEMON_ORDER);
}
public void setLogicsInstance(LogicsInstance logicsInstance) { this.logicsInstance = logicsInstance; }
@Override public LogicsInstance getLogicsInstance() { return logicsInstance; }
@Override public String getEventName() { return "data-sync"; }
@Override
public void afterPropertiesSet() {
Assert.notNull(logicsInstance, "logicsInstance must be specified");
}
@Override
protected void onInit(LifecycleEvent event) {
ScriptingLogicsModule LM = getLogicsInstance().getBusinessLogics().getModule("DataSync");
Assert.notNull(LM, "DataSync module not found");
try {
lastSyncedAt = LM.findProperty("lastSyncedAt[]");
totalRecordCount = LM.findProperty("totalRecordCount[]");
} catch (ScriptingErrorLog.SemanticErrorException e) {
throw new RuntimeException(e);
}
}
@Override
protected void onStarted(LifecycleEvent event) {
executor = ExecutorFactory.createMonitorScheduledThreadService(1, this);
executor.scheduleAtFixedRate(this::syncOnce, 0, 1, TimeUnit.MINUTES);
}
@Override
protected void onStopping(LifecycleEvent event) {
if (executor != null) executor.shutdownNow();
}
private void syncOnce() {
BusinessLogics BL = getLogicsInstance().getBusinessLogics();
try (DataSession session = createSession()) {
int recordCount = fetchExternalCount();
lastSyncedAt.change(LocalDateTime.now(), session);
totalRecordCount.change(recordCount, session);
session.applyException(BL, getStack());
} catch (Exception e) {
// periodic task: log and let the next tick retry
// (any uncaught exception — including runtime ones from fetchExternalCount —
// would suppress further runs of scheduleAtFixedRate)
logger.error("data sync failed", e);
}
}
private int fetchExternalCount() {
// call external system
return 0;
}
}
ExecutorFactory.createMonitorScheduledThreadService(threads, this) brings up threads in the monitor context, so getStack() works inside the tasks. Exceptions inside the task must not propagate — scheduleAtFixedRate would suppress further runs, so errors are logged.