Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions build/bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,17 @@
<version>${version.ai.timefold.solver}</version>
<classifier>sources</classifier>
</dependency>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-service-storage-inmemory</artifactId>
<version>${version.ai.timefold.solver}</version>
</dependency>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-service-storage-inmemory</artifactId>
<version>${version.ai.timefold.solver}</version>
<classifier>sources</classifier>
</dependency>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-service-jackson</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package ai.timefold.solver.service.definition.impl.storage;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PushbackInputStream;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

/**
* Utility methods to compress and decompress content that is stored in a
* {@link ai.timefold.solver.service.definition.internal.storage.Storage}.
* <p>
* Content is compressed with gzip. As storages can also contain content that was not written by the service, all
* decompression methods first check for the gzip magic bytes and pass the content through unchanged when it is not
* compressed.
*/
public class CompressionUtils {

private static final int GZIP_MAGIC_LENGTH = 2;

private CompressionUtils() {
}

public static byte[] compress(byte[] data) {
byte[] processed;
var os = new ByteArrayOutputStream();
try (var gzipOs = new GZIPOutputStream(os)) {

gzipOs.write(data, 0, data.length);
// the deflated data and the trailer only reach the underlying stream once the gzip stream is finished
gzipOs.finish();

processed = os.toByteArray();
} catch (IOException e) {
processed = data;
}
return processed;
}

public static byte[] uncompress(byte[] data) {
if (isCompressed(data)) {

var os = new ByteArrayOutputStream();

try (var gis = new GZIPInputStream(new ByteArrayInputStream(data))) {
gis.transferTo(os);

return os.toByteArray();
} catch (IOException e) {
return data;
}
} else {
return data;
}
}

public static boolean isCompressed(final byte[] compressed) {
return compressed.length >= GZIP_MAGIC_LENGTH
&& (compressed[0] == (byte) (GZIPInputStream.GZIP_MAGIC))
&& (compressed[1] == (byte) (GZIPInputStream.GZIP_MAGIC >> 8));
}

/**
* Wraps given stream into a decompressing one, in case its content is compressed.
*
* @param source stream to read the content from
* @return stream that provides the uncompressed content, closing it closes the source stream as well
*/
public static InputStream decompressIfNeeded(InputStream source) throws IOException {
var pushback = new PushbackInputStream(source, GZIP_MAGIC_LENGTH);
var magic = new byte[GZIP_MAGIC_LENGTH];
int read = pushback.read(magic);
if (read > 0) {
pushback.unread(magic, 0, read);
}
if (read == GZIP_MAGIC_LENGTH && isCompressed(magic)) {
return new GZIPInputStream(pushback);
}
return pushback;
}

/**
* Transfers the content of the source stream to the target one, compressing it on the fly in case it is not
* compressed already.
*
* @param source stream to read the content from
* @param target stream to write the (compressed) content to
*/
public static void transferDataCompressIfNeeded(InputStream source, OutputStream target) throws IOException {
byte[] twoFirst = source.readNBytes(GZIP_MAGIC_LENGTH);
if (isCompressed(twoFirst)) {
target.write(twoFirst);
source.transferTo(target);
} else {
var gzipOut = new GZIPOutputStream(target);
gzipOut.write(twoFirst);
source.transferTo(gzipOut);
gzipOut.finish();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package ai.timefold.solver.service.definition.impl.storage;

import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Produces StorageObjectMapper instances with storage-specific (de)serialization settings.
*/
public class StorageObjectMapperProducer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No beans in the definition module, please. It can silently alternate the behavior in any module that depends on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed ApplicationScoped from the class.


private ObjectMapper quarkusObjectMapper;

@Inject
public StorageObjectMapperProducer(ObjectMapper quarkusObjectMapper) {
this.quarkusObjectMapper = quarkusObjectMapper;
}

@Produces
public StorageObjectMapperWrapper create() {
var objectMapper = quarkusObjectMapper.copy(); // Create a copy to avoid mutating the injected instance.
/*
* Storage-specific customizations are more permissive (beyond the model JSON Schema):
* 1. Ignore unknown properties during deserialization to allow for forward compatibility.
* 2. Enable case-insensitive property matching as some cloud storages are case-insensitive.
*/
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
objectMapper.getDeserializationConfig().with(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);

objectMapper.findAndRegisterModules();

return new StorageObjectMapperWrapper(objectMapper);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ai.timefold.solver.service.definition.impl.storage;

import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Wrapper around ObjectMapper to be injected into storage services to enforce correct (de)serialization settings.
* <p>
* Instances are normally produced by the {@link StorageObjectMapperProducer}.
*/
public final class StorageObjectMapperWrapper {

private final ObjectMapper objectMapper;

public StorageObjectMapperWrapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

public ObjectMapper get() {
return objectMapper;
}
}

This file was deleted.

Loading
Loading