Skip to content

Commit

Permalink
feat: added module embedded aerospike enterprise with configured mand…
Browse files Browse the repository at this point in the history
…atory durable deletes by default
  • Loading branch information
Volchkov Andrey authored and Fameing committed Jan 16, 2024
1 parent 3c1bf21 commit 6593774
Show file tree
Hide file tree
Showing 20 changed files with 570 additions and 2 deletions.
71 changes: 71 additions & 0 deletions embedded-aerospike-enterprise/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
=== embedded-aerospike-enterprise

TIP: This module provides integration with https://github.com/Shopify/toxiproxy[ToxiProxy] out of the box.
ToxiProxy is a great tool for simulating network conditions, meaning that you can test your application's resiliency.

==== Difference with `embedded-aerospike` module

* Aerospike Enterprise container version must be not less then 6.3.0, because of option for disallow non-durable deletes.
* By default disallow [non-durable deletes](#Disallow non-durable deletes).

==== Maven dependency

.pom.xml
[source,xml]
----
<dependency>
<groupId>com.playtika.testcontainers</groupId>
<artifactId>embedded-aerospike-enterprise</artifactId>
<scope>test</scope>
</dependency>
----

==== Consumes (via `bootstrap.properties`)

* `embedded.aerospike.enabled` `(true|false, default is 'true')`
* `embedded.aerospike.reuseContainer` `(true|false, default is 'false')`
* `embedded.aerospike.dockerImage` `(default is set to 'aerospike/aerospike-server-enterprise:6.3.0.16_1')`
** Aerospike Enterprise version must be not less then 6.3.0
* `embedded.aerospike.featureKey` base64 of a feature-key-file https://aerospike.com/docs/server/operations/configure/feature-key, default is null.
**Warning: if not provided, the Aerospike Database EE evaluation feature key file will be used. That means you can use it internally only for Evaluation
purposes only during the Evaluation Period**. See https://github.com/aerospike/aerospike-server.docker/blob/master/enterprise/ENTERPRISE_LICENSE`
* `embedded.aerospike.waitTimeoutInSeconds` `(default is 60 seconds)`
* `embedded.toxiproxy.proxies.aerospike.enabled` Enables both creation of the container with ToxiProxy TCP proxy and a proxy to the `embedded-aerospike` container.
* `embedded.aerospike.time-travel.enabled` Enables time travel to clean expired documents by time. Does not work on ARM(mac m1) because of LUA scripts are not supported on ARM.
* `embedded.aerospike.enterprise.durableDeletes` Enables disallow non-durable deletes for Aerospike Enterprise Server. By default is true.
* https://mvnrepository.com/artifact/com.aerospike/aerospike-client[aerospike client library]

==== Produces

* `embedded.aerospike.host`
* `embedded.aerospike.port`
* `embedded.aerospike.namespace`
* `embedded.aerospike.toxiproxy.host`
* `embedded.aerospike.toxiproxy.port`
* `embedded.aerospike.networkAlias`
* `embedded.aerospike.internalPort`
* Bean `AerospikeTestOperations aerospikeTestOperations`
* Bean `ToxiproxyContainer.ContainerProxy aerospikeContainerProxy`

==== Example

See `embedded-aerospike` module readme for examples.

==== Enterprise features

===== Disallow non-durable deletes

Aerospike server never delete record from disk(SSD), but the index in memory that points to the record is removed.
If the location on disk of the deleted record was not overwritten prior to reboot, the record will be indexed during cold restart.
The record then returns from the disk to the database as a zombie record.

To avoid this, Aerospike provide a feature called https://aerospike.com/docs/server/guide/durable_deletes[Durable Deletes].
Durable deletes typically free storage when they generate a tombstone, a record without any bins that contains all metadata including the key.
Tombstones correctly resolve conflicts and prevent previously persisted versions of deleted objects from resurrecting when the index is repopulated.
This feature is available only for Aerospike Enterprise Edition.

This library reconfigure com.playtika.testcontainers:embedded-aerospike
to use Aerospike Enterprise Edition docker image, and set up Durable Deletes feature as mandatory.
If the client call delete operation without setting WritePolicy.durableDeletes to true, the operation
will fail with Aerospike Error 22 (forbidden). This is done by configuring aerospike server with option
https://aerospike.com/docs/server/reference/configuration#disallow-expunge[disallow-expunge].
41 changes: 41 additions & 0 deletions embedded-aerospike-enterprise/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<artifactId>testcontainers-spring-boot-parent</artifactId>
<groupId>com.playtika.testcontainers</groupId>
<version>3.1.1</version>
<relativePath>../testcontainers-spring-boot-parent</relativePath>
</parent>

<artifactId>embedded-aerospike-enterprise</artifactId>

<properties>
<aerospike-client.version>7.2.0</aerospike-client.version>
</properties>

<dependencies>
<!--
aerospike client is provided since we want users to pick own version,
and not rely on test library
-->
<dependency>
<groupId>com.aerospike</groupId>
<artifactId>aerospike-client</artifactId>
<version>${aerospike-client.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.playtika.testcontainers</groupId>
<artifactId>embedded-aerospike</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.playtika.testcontainers.aerospike.enterprise;

import com.playtika.testcontainer.aerospike.AerospikeProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.GenericContainer;

import java.io.IOException;

@RequiredArgsConstructor
@Slf4j
public class AerospikeEnterpriseConfigurer {

private final AerospikeProperties aerospikeProperties;
private final AerospikeEnterpriseProperties enterpriseProperties;

public void configure(GenericContainer<?> aerospikeContainer) throws IOException, InterruptedException {
if (aerospikeProperties.getFeatureKey() == null || aerospikeProperties.getFeatureKey().isBlank()) {
log.warn("Evaluation feature key file not provided by 'embedded.aerospike.featureKey' property. " +
"Pay attention to license details: https://github.com/aerospike/aerospike-server.docker/blob/master/enterprise/ENTERPRISE_LICENSE");
}

setupDisallowExpunge(aerospikeContainer);
}

private void setupDisallowExpunge(GenericContainer<?> aerospikeContainer) throws IOException, InterruptedException {
if (!enterpriseProperties.isDurableDeletes()) {
return;
}
log.info("Setting up 'disallow-expunge' to true...");
String namespace = aerospikeProperties.getNamespace();
Container.ExecResult result = aerospikeContainer.execInContainer("asadm", "-e",
String.format("enable; manage config namespace %s param disallow-expunge to true", namespace));
if (result.getStderr().length() > 0) {
throw new IllegalStateException("Failed to set up 'disallow-expunge' to true: " + result.getStderr());
}
log.info("Set up 'disallow-expunge' to true: {}", result.getStdout());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.playtika.testcontainers.aerospike.enterprise;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties("embedded.aerospike.enterprise")
public class AerospikeEnterpriseProperties {

boolean durableDeletes = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.playtika.testcontainers.aerospike.enterprise;

import com.aerospike.client.AerospikeClient;
import com.playtika.testcontainer.aerospike.AerospikeExpiredDocumentsCleaner;
import com.playtika.testcontainer.aerospike.AerospikeProperties;
import com.playtika.testcontainer.aerospike.EmbeddedAerospikeTestOperationsAutoConfiguration;
import com.playtika.testcontainer.aerospike.ExpiredDocumentsCleaner;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.aerospike.AerospikeAutoConfiguration",
before = EmbeddedAerospikeTestOperationsAutoConfiguration.class)
@ConditionalOnExpression("${embedded.containers.enabled:true}")
@ConditionalOnBean({AerospikeClient.class, AerospikeProperties.class})
@ConditionalOnProperty(value = "embedded.aerospike.enabled", matchIfMissing = true)
public class EnterpriseAerospikeTestOperationsAutoConfiguration {

@Bean
@ConditionalOnProperty(value = "embedded.aerospike.time-travel.enabled", havingValue = "true", matchIfMissing = true)
public ExpiredDocumentsCleaner expiredDocumentsCleaner(AerospikeClient client,
AerospikeProperties properties) {
return new AerospikeExpiredDocumentsCleaner(client, properties.getNamespace(), true);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.playtika.testcontainers.aerospike.enterprise;

import lombok.NonNull;

import java.util.Comparator;

record ImageVersion (int major, int minor) implements Comparable<ImageVersion> {

static ImageVersion parse(String version) {
String[] parts = version.split("\\.");
if (parts.length < 2) {
throw new IllegalArgumentException("Invalid version: " + version);
}
try {
int major = Integer.parseInt(parts[0]);
int minor = Integer.parseInt(parts[1]);
return new ImageVersion(major, minor);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid version: " + version, e);
}
}

@Override
public int compareTo(@NonNull ImageVersion o) {
return Comparator.comparingInt(ImageVersion::major)
.thenComparingInt(ImageVersion::minor)
.compare(this, o);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.playtika.testcontainers.aerospike.enterprise;

import com.playtika.testcontainer.aerospike.AerospikeProperties;
import com.playtika.testcontainer.aerospike.EmbeddedAerospikeBootstrapConfiguration;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;
import org.testcontainers.containers.GenericContainer;

import java.io.IOException;

@Slf4j
@AutoConfiguration(after = EmbeddedAerospikeBootstrapConfiguration.class)
@ConditionalOnExpression("${embedded.containers.enabled:true}")
@ConditionalOnProperty(value = "embedded.aerospike.enabled", matchIfMissing = true)
@EnableConfigurationProperties(AerospikeEnterpriseProperties.class)
@PropertySource("classpath:/embedded-enterprise-aerospike.properties")
public class SetupEnterpriseAerospikeBootstrapConfiguration {

private static final String DOCKER_IMAGE = "aerospike/aerospike-server-enterprise:6.3.0.16_1";
private static final String AEROSPIKE_DOCKER_IMAGE_PROPERTY = "embedded.aerospike.dockerImage";
private static final ImageVersion SUITABLE_IMAGE_VERSION = new ImageVersion(6, 3);
private static final String TEXT_TO_DOCUMENTATION = "Documentation: https://github.com/PlaytikaOSS/testcontainers-spring-boot/blob/develop/embedded-aerospike-enterprise/README.adoc";

private GenericContainer<?> aerospikeContainer;
private AerospikeProperties aerospikeProperties;
private AerospikeEnterpriseProperties aerospikeEnterpriseProperties;
private Environment environment;

@Autowired
public void setEnvironment(Environment environment) {
this.environment = environment;
}

@Autowired
@Qualifier(AerospikeProperties.BEAN_NAME_AEROSPIKE)
public void setAerospikeContainer(GenericContainer<?> aerospikeContainer) {
this.aerospikeContainer = aerospikeContainer;
}

@Autowired
public void setAerospikeProperties(AerospikeProperties aerospikeProperties) {
this.aerospikeProperties = aerospikeProperties;
}

@Autowired
public void setAerospikeEnterpriseProperties(AerospikeEnterpriseProperties aerospikeEnterpriseProperties) {
this.aerospikeEnterpriseProperties = aerospikeEnterpriseProperties;
}

@PostConstruct
public void setupEnterpriseAerospike() throws IOException, InterruptedException {
verifyAerospikeImage();
AerospikeEnterpriseConfigurer aerospikeEnterpriseConfigurer = new AerospikeEnterpriseConfigurer(aerospikeProperties, aerospikeEnterpriseProperties);
aerospikeEnterpriseConfigurer.configure(aerospikeContainer);
}

private void verifyAerospikeImage() {
log.info("Verify Aerospike Enterprise Image");

String dockerImage = environment.getProperty(AEROSPIKE_DOCKER_IMAGE_PROPERTY);
if (dockerImage == null) {
throw new IllegalStateException("Aerospike enterprise docker image not provided, set up 'embedded.aerospike.dockerImage' property.\n"
+ TEXT_TO_DOCUMENTATION);
}

if (!isEnterpriseImage(dockerImage)) {
throw illegalAerospikeImageException();
}
}

private IllegalStateException illegalAerospikeImageException() {
return new IllegalStateException(
"You should use enterprise image for the Aerospike container with equal or higher version: " + DOCKER_IMAGE + ". "
+ "Container enable 'disallow-expunge' config option to prevent non-durable deletes, and this config option is available starting with version 6.3. "
+ "Enterprise image is required, as non-durable deletes are not available in Community."
+ TEXT_TO_DOCUMENTATION
);
}

private boolean isEnterpriseImage(String dockerImage) {
return dockerImage.contains("enterprise")
&& isSuitableVersion(dockerImage);
}

private boolean isSuitableVersion(String dockerImage) {
int index = dockerImage.indexOf(":");
if (index == -1) {
throw new IllegalStateException("Invalid docker image version format: " + dockerImage + ".\n"
+ TEXT_TO_DOCUMENTATION);
}
String version = dockerImage.substring(index + 1);
ImageVersion imageVersion = ImageVersion.parse(version);
return imageVersion.compareTo(SUITABLE_IMAGE_VERSION) >= 0;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
com.playtika.testcontainers.aerospike.enterprise.SetupEnterpriseAerospikeBootstrapConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.playtika.testcontainers.aerospike.enterprise.EnterpriseAerospikeTestOperationsAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
embedded.aerospike.dockerImage=aerospike/aerospike-server-enterprise:6.3.0.16_1
Loading

0 comments on commit 6593774

Please sign in to comment.