Skip to content

Commit

Permalink
feat(agama): use a mixed strategy for serialization (#6883)
Browse files Browse the repository at this point in the history
* chore: misc enhancements #6834

* feat: implement a mixed strategy for serialization of state #6834

* feat: update configuration for #6834

* docs: update docs in accordance to new serialization behavior #6834
  • Loading branch information
jgomer2001 committed Dec 1, 2023
1 parent 45eedd2 commit 00aee0c
Show file tree
Hide file tree
Showing 20 changed files with 215 additions and 170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public class Transpiler {
throw new RuntimeException("Unable to read utility script", e);
}

FM_CONFIG = new Configuration(Configuration.VERSION_2_3_31);
FM_CONFIG = new Configuration(Configuration.VERSION_2_3_32);
FM_CONFIG.setClassLoaderForTemplateLoading(CLS_LOADER, "/");
FM_CONFIG.setDefaultEncoding(UTF_8.toString());
FM_CONFIG.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
Expand Down
35 changes: 32 additions & 3 deletions docs/admin/developer/agama/engine-bridge-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ The properties of Agama engine configuration are described in the following:

- `scriptsPath`: A path relative to `/opt/jans/jetty/jans-auth/server/agama` that serves as the root of the hierarchy of (Java/Groovy) classes added on the fly. Default value is `/scripts`

- `serializerType`: A low-level property related to [continuations](./advanced-usages.md#other-engine-characteristics) serialization. Set this to `null` if your flows present crashes due to issues with Java serialization. Default value is `KRYO`

- `maxItemsLoggedInCollections`: When a list or map is [logged](../../../agama/language-reference.md#logging)
in
a flow, only the first few items are included in the output. You can use this property to increase that limit. Default value is `9`
Expand All @@ -44,10 +42,41 @@ The properties of Agama engine configuration are described in the following:

- `bridgeScriptPage`: This is a facelets (JSF) page the bridge needs for proper operation. This page resides in the authentication server WAR file and will rarely need modifications. Default value is `agama.xhtml`

- `serializeRules`: A JSON object specifying the serialization rules, see below. It is not recommended to remove items from the out-of-the-box rules. Adding items is fine

<!--
- `defaultResponseHeaders`: A JSON object : {
"Expires": "0"
}-->
}-->

### Serialization rules

At certain points in the course of a flow, serialization of all its variables is required. The engine employs two mechanisms for this purpose: standard Java serialization and [KRYO](https://github.com/EsotericSoftware/kryo) serialization. Depending on the type of (Java) object to be serialized, administrators can specify when a mechanism is preferred over the other through a set of simple rules.

This can be better explained with an example. Suppose the following configuration:

```
"serializeRules": {
"JAVA": ["ice", "com.acme"],
"KRYO": [ "com.acme.bike" ]
}
```

- If the object to serialize belongs to class `com.acme.bike.SuperSonic`, both lists are traversed for the best package match. Here KRYO wins because it has a perfect match with respect to the package of the class

- If the class were `com.acme.bike.mega.SuperSonic`, KRYO still wins because it has the closest match to the package of the class

- In case of `ice.cream.Salty`, JAVA is chosen (best match)

- In case of `org.buskers.Singer`, no matches are found, however, KRYO is chosen - it's the **fallback** method

- In case of `com.acmeMan`, no matches are found. KRYO is picked as in the previous case

Please account additional behaviors:

- If the object's class is in the default package (unnamed package), KRYO is used
- If the exact class name is found in one of the lists, the method represented by such list is employed
- If the object is a (Java) exception, JAVA is used unless the full class name appears listed in the KRYO rules

## Bridge configuration

Expand Down
17 changes: 11 additions & 6 deletions docs/admin/developer/agama/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ This occurs when no `Finish` statement has been found in the execution of a flow

### Serialization errors

Agama engine saves the state of a flow every time an [RRF](../../../agama/language-reference.md#rrf) or [RFAC](../../../agama/language-reference.md#rfac) instruction is reached. For this purpose the [KRYO](https://github.com/EsotericSoftware/kryo) library is employed. If kryo is unable to serialize a variable in use by your flow, a serialization error will appear in the screen or in the logs. Normally the problematic (Java) class is logged and this helps reveal the variable that is causing the issue. Note variables that hold "native" Agama values like strings or maps are never troublesome; the problems may originate from values obtained via [Call](../../../agama/language-reference.md#foreign-routines).
Agama engine saves the state of a flow every time an [RRF](../../../agama/language-reference.md#rrf) or [RFAC](../../../agama/language-reference.md#rfac) instruction is reached. The _State_ can be understood as the set of all variables defined in a flow and their associated values up to certain point.

To fix a serialization problem, try some of the following:
Most of times, variables hold basic Agama [values](../../../agama/language-reference.md#data-types) like strings, booleans, numbers, lists or maps, however, more complex values may appear due to Java `Call`s. The engine does its best to properly serialize these Java objects, nonetheless, this is not always feasible. In these cases, the flow crashes and errors will appear on screen as well as in the server logs.

- Check if the value held by the variable is needed for RRF/RFAC or some upcoming statement. If that's not the case, simply set it to `null` before RRF/RFAC occurs
- Adjust the given class so it is "serialization" friendlier. With kryo, classes are not required to implement the `java.io.Serializable` interface
- Find a replacement for the problematic class
- As a last resort, set `serializerType` property of the [engine](./engine-bridge-config.md#engine-configuration) to `null`. Note this will switch to standard Java serialization. This setting applies globally for all your flows
Use the information in the logs to detect the problematic Java class. This would normally allow you to identify the variable that is causing the issue. Now you have several options to proceed:

- Check if the value held by the variable is needed for the given RRF/RFAC or some upcoming statement. If that's not the case, simply set it to `null` before RRF/RFAC occurs
- Extract only the necessary pieces from the variable, that is, grab only the fields from the object which are of use for the rest of the flow. If they are simple values like strings or numbers, serialization will succeed. Ensure to nullify or overwrite the original variable
- Adjust the given class so it is "serialization" friendlier. Sometimes, adding a no-args constructor fixes the problem. In other cases, making the class implement the `java.io.Serializable` interface will make it work. The error in the log should provide a hint
- Tweak the engine serialization [rules](./engine-bridge-config.md#serialization-rules) so an alternative type of serialization can be used for this particular object
- Modify your Java code so an alternative class is used instead

In general, a good practice is to avoid handling complex variables in flows. Letting big object graphs live in the state has a negative impact on performance and also increases the risk of serialization issues.

## Libraries and classes added on the fly

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

public class FlowCrashException extends Exception {

public FlowCrashException() {}

public FlowCrashException(String message) {
super(message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ public static Pair<Object, Exception> callAction(Object instance, String actionC
try {
result = CdiUtil.bean(ActionService.class).callAction(instance, actionClassName, methodName, params);
} catch (Exception e) {
LOG.warn("Exception raised when executing Call - class: {}, method: {}", actionClassName, methodName);
LOG.warn("Exception raised when executing Call - class: {}, method: {}",
actionClassName == null ? instance.getClass().getName() : actionClassName, methodName);
ex = e;
}
return new Pair<>(result, ex); //See jans#6530
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class CustomObjectOutputStream extends ObjectOutputStream {
try ( ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream sos = new CustomObjectOutputStream(baos)) {

//Pair is not java-serializable, use a 2-length array
//Pair is not serialization-friendly, use a 2-length array
sos.writeObject(new Object[] { scope, continuation });
return baos.toByteArray();
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@
import java.io.InputStream;
import java.io.OutputStream;

import io.jans.agama.model.serialize.Type;
import io.jans.agama.engine.service.ActionService;

import org.slf4j.Logger;

@ApplicationScoped
public class KryoSerializer implements ObjectSerializer {
public class KryoSerializer {

@Inject
private Logger logger;
Expand All @@ -27,34 +26,31 @@ public class KryoSerializer implements ObjectSerializer {
private ActionService actionService;

private ThreadLocal<Kryo> kryos;
@Override
public Object deserialize(InputStream in) throws IOException {

public Object deserialize(InputStream in) {

logger.trace("Kryodeserializing");
Input input = new Input(in);
//If input is closed, the input's InputStream is closed
return kryos.get().readClassAndObject(input);

}

@Override

public void serialize(Object data, OutputStream out) throws IOException {

logger.trace("Kryoserializing");
Output output = new Output(out);
kryos.get().writeClassAndObject(output, data);
output.flush();

}

@Override
public Type getType() {
return Type.KRYO;
}


@PostConstruct
private void init() {

Log.DEBUG();
kryos = new ThreadLocal<Kryo>() {

@Override
protected Kryo initialValue() {
Kryo kryo = new Kryo();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class NativeJavaBox implements Serializable {
private static final Logger logger = LoggerFactory.getLogger(NativeJavaBox.class);

private static final ManagedBeanService MBSRV = CdiUtil.bean(ManagedBeanService.class);
private static final SerializerFactory SERFACT = CdiUtil.bean(SerializerFactory.class);
private static final SerializationUtil INUTIL = CdiUtil.bean(SerializationUtil.class);
private static final ActionService ACTSRV = CdiUtil.bean(ActionService.class);

private NativeJavaObject raw;
Expand Down Expand Up @@ -71,18 +71,7 @@ private void writeObject(ObjectOutputStream out) throws IOException {
out.writeUTF(realClassName);
out.writeObject(qualies); //kryo fails deserializing Annotations :(
} else {

//The object serializer instance may change at runtime. It has to be looked up every time
ObjectSerializer serializer = SERFACT.get();
boolean useJavaOnlySerialization = serializer == null;

out.writeObject(useJavaOnlySerialization ? null : serializer.getType());
//unwrapped is not a managed object
if (useJavaOnlySerialization) {
out.writeObject(unwrapped);
} else {
serializer.serialize(unwrapped, out);
}
INUTIL.write(unwrapped, out);
}

}
Expand All @@ -103,10 +92,7 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE
logger.trace("Managed bean class {}", realClassName);
unwrapped = ManagedBeanService.instance(ACTSRV.classFromName(realClassName), qualies);
} else {

Type type = (Type) in.readObject();
ObjectSerializer serializer = SERFACT.get(type);
unwrapped = serializer == null ? in.readObject() : serializer.deserialize(in);
unwrapped = INUTIL.read(in);
}

logger.trace("Underlying object is an instance of {}", unwrapped.getClass().getName());
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package io.jans.agama.engine.serialize;

import io.jans.agama.model.EngineConfig;
import io.jans.agama.model.serialize.Type;
import io.jans.util.StringHelper;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.*;

import org.slf4j.Logger;

import static io.jans.agama.model.serialize.Type.*;

@ApplicationScoped
public class SerializationUtil {

@Inject
private KryoSerializer criolina;

@Inject
private EngineConfig econfig;

@Inject
private Logger logger;

public void write(Object obj, ObjectOutputStream out) throws IOException {

Type type = typeFor(obj);
logger.trace("Serialization strategy chosen was {}", type);
boolean useKryo = type.equals(KRYO);
out.writeBoolean(useKryo);

if (useKryo) {
criolina.serialize(obj, out);
} else {
out.writeObject(obj);
}

}

public Object read(ObjectInputStream in) throws IOException, ClassNotFoundException {
return in.readBoolean() ? criolina.deserialize(in) : in.readObject();
}

// For a given object, it determines the serializer to use given the following hints:
// - If the object's class is in the default package (unnamed package), use kryo
// - If the object is an exception use java unless the class name appears in the kryo rules of agama config
// - Use kryo or java based on the largest package match found in the rules. When in a tie, use kryo
// The below are examples of package matches against hypothetical class ab.ur.ri.do
// a) ab.ur.ri
// b) ab
// Here, match (a) is better (larger). The below are NOT matches against the mentioned class:
// a) ab.ur.ri.do.s
// b) ab.ur.ri.dor
private Type typeFor(Object budget) {

Map<String, List<String>> rules = econfig.getSerializeRules();
if (rules == null) return KRYO;

String clsName = budget.getClass().getName();
List<String> jules = aList(rules, JAVA);
List<String> kules = aList(rules, KRYO);

int kryoScore = score(clsName, kules);
if (kryoScore == -1) return KRYO;

if (Exception.class.isInstance(budget)) {
//Use Java for serializing exceptions except when the full classname is found in the kryo rules
return kryoScore == 0 ? KRYO : JAVA;
} else {
int jScore = score(clsName, jules);
return kryoScore <= jScore ? KRYO : JAVA;
}

}

private static int score(String clsName, List<String> prefixes) {

int parts = dotCount(clsName);
if (parts == 0) return -1; //this class is in the default package!

parts++;
int sc = 0; //holds the largest match against a package

for (String pack : prefixes) {
if (StringHelper.isNotEmptyString(pack)) {
int packageParts = dotCount(pack) + 1;

if (parts > packageParts) {
if (clsName.startsWith(pack + ".") && sc < packageParts)
sc = packageParts;
} else if (parts == packageParts) { //clsName does not belong to package pack
if (pack.equals(clsName)) return 0; //perfect match (pack is actually a classname)
}
}
}
return parts - sc;

}

private static int dotCount(String str) {

int s = -1, i = -1;
do {
i++;
s++;
i = str.indexOf('.', i);
} while (i != -1);
return s;

}

private static List<String> aList(Map<String, List<String>> map, Type type) {
return map.getOrDefault(type.toString(), Collections.emptyList());
}

}
Loading

0 comments on commit 00aee0c

Please sign in to comment.