All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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())
                                    .justid());
                          }
                        });
              }
              // 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