
io.zeebe.redis.exporter.RedisCleaner Maven / Gradle / Ivy
package io.zeebe.redis.exporter;
import io.lettuce.core.*;
import io.lettuce.core.api.sync.RedisStreamCommands;
import io.lettuce.core.api.sync.RedisStringCommands;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
public class RedisCleaner {
private static final String CLEANUP_LOCK = "zeebe:cleanup-lock";
private static final String CLEANUP_TIMESTAMP = "zeebe:cleanup-time";
private final Logger logger;
private UniversalRedisConnection redisConnection;
private Map streams = new ConcurrentHashMap<>();
private boolean useProtoBuf;
private long maxTtlInMillisConfig;
private long minTtlInMillisConfig;
private boolean deleteAfterAcknowledge;
private long consumerJobTimeout;
private long consumerIdleTimeout;
private Duration trimScheduleDelay;
public RedisCleaner(
UniversalRedisConnection redisConnection,
boolean useProtoBuf,
ExporterConfiguration config,
Logger logger) {
this.logger = logger;
this.redisConnection = redisConnection;
this.useProtoBuf = useProtoBuf;
minTtlInMillisConfig = config.getMinTimeToLiveInSeconds() * 1000L;
if (minTtlInMillisConfig < 0) minTtlInMillisConfig = 0;
maxTtlInMillisConfig = config.getMaxTimeToLiveInSeconds() * 1000L;
if (maxTtlInMillisConfig < 0) maxTtlInMillisConfig = 0;
deleteAfterAcknowledge = config.isDeleteAfterAcknowledge();
consumerJobTimeout = config.getConsumerJobTimeoutInSeconds() * 1000L;
consumerIdleTimeout = config.getConsumerIdleTimeoutInSeconds() * 1000L;
trimScheduleDelay = Duration.ofSeconds(config.getCleanupCycleInSeconds());
}
public void setRedisConnection(UniversalRedisConnection redisConnection) {
this.redisConnection = redisConnection;
}
public void considerStream(String stream) {
streams.put(stream, Boolean.TRUE);
}
public void trimStreamValues() {
if (redisConnection != null && streams.size() > 0 && acquireCleanupLock()) {
try {
// get ID according to max time to live
final long maxTTLMillis = System.currentTimeMillis() - maxTtlInMillisConfig;
final String maxTTLId = String.valueOf(maxTTLMillis);
// get ID according to min time to live
final long minTTLMillis = System.currentTimeMillis() - minTtlInMillisConfig;
final String minTTLId = String.valueOf(minTTLMillis);
logger.debug("trim streams {}", streams);
// trim all streams
List keys = new ArrayList(streams.keySet());
RedisStreamCommands streamCommands = redisConnection.syncStreamCommands();
keys.forEach(
stream -> {
// 1. always trim stream according to maxTTL
// side effect: prepares deleting too old pending messages in step 3
if (maxTtlInMillisConfig > 0) {
long numMaxTtlTrimmed =
streamCommands.xtrim(stream, new XTrimArgs().minId(maxTTLId));
if (numMaxTtlTrimmed > 0) {
logger.debug("{}: {} cleaned records", stream, numMaxTtlTrimmed);
}
}
// 2. get all consumer groups and add detailed consumer data
var consumerGroups =
streamCommands.xinfoGroups(stream).stream()
.map(o -> XInfoGroup.fromXInfo(o, useProtoBuf))
.toList();
consumerGroups.forEach(
xi ->
xi.setConsumers(
streamCommands.xinfoConsumers(stream, xi.getName()).stream()
.map(o -> XInfoConsumer.fromXInfo(o, useProtoBuf))
.sorted(Comparator.comparingLong(XInfoConsumer::getIdle))
.toList()));
// 3. auto claim abandoned messages using the youngest consumer
// side effect: delete too old pending messages (according to max TTL)
if (consumerJobTimeout > 0) {
consumerGroups.stream()
.filter(xi -> xi.getPending() > 0)
.forEach(
xi -> {
var youngestConsumer = xi.getYoungestConsumer();
if (youngestConsumer.isPresent()) {
var consumer =
Consumer.from(xi.getName(), youngestConsumer.get().getName());
streamCommands.xautoclaim(
stream,
new XAutoClaimArgs()
.consumer(consumer)
.minIdleTime(consumerJobTimeout)
.count(xi.getPending()));
}
});
}
// 4. trim according to last-delivered-id considering pending messages
Optional minDelivered =
!deleteAfterAcknowledge
? Optional.empty()
: consumerGroups.stream()
.map(
xi -> {
if (xi.getPending() > 0) {
xi.considerPendingMessageId(
streamCommands
.xpending(stream, xi.getName())
.getMessageIds()
.getLower()
.getValue());
}
return xi.getLastDeliveredId();
})
.min(Comparator.comparing(Long::longValue));
if (minDelivered.isPresent()) {
var doTrim = true;
long minDeliveredMillis = minDelivered.get();
String xtrimMinId = String.valueOf(minDeliveredMillis);
if (maxTtlInMillisConfig > 0 && maxTTLMillis > minDeliveredMillis) {
doTrim = false; // xtrim with max TTL already happened
} else if (minTtlInMillisConfig > 0 && minTTLMillis < minDeliveredMillis) {
xtrimMinId = minTTLId;
}
if (doTrim) {
long numTrimmed = streamCommands.xtrim(stream, new XTrimArgs().minId(xtrimMinId));
if (numTrimmed > 0) {
logger.debug("{}: {} cleaned records", stream, numTrimmed);
}
}
}
// 5. delete inactive consumers
if (consumerIdleTimeout > 0) {
consumerGroups.forEach(
group -> {
var consumers = new ArrayList<>(group.getConsumers());
if (consumers.isEmpty()) return;
var youngestConsumer = consumers.remove(0);
consumers.stream()
.filter(
consumer ->
consumer.getPending() == 0
&& consumer.getIdle()
> youngestConsumer.getIdle() + consumerIdleTimeout)
.forEach(
consumer -> {
long numDeleted =
streamCommands.xgroupDelconsumer(
stream, Consumer.from(group.getName(), consumer.getName()));
if (numDeleted > 0) {
logger.debug(
"{}: {} deleted consumers of group {}",
stream,
numDeleted,
group.getName());
}
});
});
}
});
} catch (RedisCommandTimeoutException | RedisConnectionException ex) {
logger.error(
"Error during cleanup due to possible Redis unavailability: {}", ex.getMessage());
} catch (Exception ex) {
logger.error("Error during cleanup", ex);
} finally {
releaseCleanupLock();
}
}
}
private boolean acquireCleanupLock() {
try {
String id = UUID.randomUUID().toString();
Long now = System.currentTimeMillis();
// ProtoBuf format
if (useProtoBuf) {
// try to get lock
RedisStringCommands stringCommands =
(RedisStringCommands) redisConnection.syncStringCommands();
stringCommands.set(
CLEANUP_LOCK,
id.getBytes(StandardCharsets.UTF_8),
SetArgs.Builder.nx().px(trimScheduleDelay));
byte[] getResult = stringCommands.get(CLEANUP_LOCK);
if (getResult != null
&& getResult.length > 0
&& id.equals(new String(getResult, StandardCharsets.UTF_8))) {
// lock successful: check last cleanup timestamp (autoscaled new Zeebe instances etc.)
byte[] lastCleanup = stringCommands.get(CLEANUP_TIMESTAMP);
if (lastCleanup == null
|| lastCleanup.length == 0
|| Long.parseLong(new String(lastCleanup, StandardCharsets.UTF_8))
< now - trimScheduleDelay.toMillis()) {
stringCommands.set(
CLEANUP_TIMESTAMP, Long.toString(now).getBytes(StandardCharsets.UTF_8));
return true;
}
}
// JSON format
} else {
// try to get lock
RedisStringCommands stringCommands =
(RedisStringCommands) redisConnection.syncStringCommands();
stringCommands.set(CLEANUP_LOCK, id, SetArgs.Builder.nx().px(trimScheduleDelay));
if (id.equals(stringCommands.get(CLEANUP_LOCK))) {
// lock successful: check last cleanup timestamp (autoscaled new Zeebe instances etc.)
String lastCleanup = stringCommands.get(CLEANUP_TIMESTAMP);
if (lastCleanup == null
|| lastCleanup.isEmpty()
|| Long.parseLong(lastCleanup) < now - trimScheduleDelay.toMillis()) {
stringCommands.set(CLEANUP_TIMESTAMP, Long.toString(now));
return true;
}
}
}
} catch (RedisCommandTimeoutException | RedisConnectionException ex) {
logger.error(
"Error acquiring cleanup lock due to possible Redis unavailability: {}", ex.getMessage());
} catch (Exception ex) {
logger.error("Error acquiring cleanup lock", ex);
}
return false;
}
private void releaseCleanupLock() {
try {
redisConnection.syncDel(CLEANUP_LOCK);
} catch (RedisCommandTimeoutException | RedisConnectionException ex) {
logger.error(
"Error releasing cleanup lock due to possible Redis unavailability: {}", ex.getMessage());
} catch (Exception ex) {
logger.error("Error releasing cleanup lock", ex);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy