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

com.arpnetworking.metrics.mad.PeriodWorker Maven / Gradle / Ivy

/*
 * Copyright 2015 Groupon.com
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.arpnetworking.metrics.mad;

import akka.actor.AbstractActor;
import com.arpnetworking.logback.annotations.LogValue;
import com.arpnetworking.metrics.mad.model.Record;
import com.arpnetworking.steno.LogValueMapFactory;
import com.arpnetworking.steno.Logger;
import com.arpnetworking.steno.LoggerFactory;
import com.google.common.collect.Lists;

import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Optional;
import java.util.TreeMap;

/**
 * Actor that aggregates a particular slice of the data set over time and metric.
 *
 * @author Ville Koskela (ville dot koskela at inscopemetrics dot io)
 */
/* package private */ final class PeriodWorker extends AbstractActor {

    /**
     * Public constructor. Since this is an {@code Actor} this method should not be
     * called directly, but instead you should use {@code Props}.
     */
    PeriodWorker(final Duration period, final Bucket.Builder bucketBuilder) {
        _period = period;
        _bucketBuilder = bucketBuilder;
        _hasRotateScheduled = false;
    }

    @Override
    public void preStart() {
        // TODO(ville): Schedule the actor to self-destruct if it doesn't receive any Record
        // instances for some period of time (e.g. > the largest period). The caveat is
        // that we need to deregister the actor from the Aggregator as well without
        // creating a race condition. Note that period worker clean-up did not exist
        // in the previous implementation either (not that it shouldn't; just one
        // problem at a time).
    }

    @Override
    public AbstractActor.Receive createReceive() {
        return receiveBuilder()
                .match(Record.class, this::processRecord)
                .matchEquals(ROTATE_MESSAGE, m -> rotateAndSchedule())
                .build();
    }

    private void rotateAndSchedule() {
        final ZonedDateTime now = ZonedDateTime.now();
        _hasRotateScheduled = false;
        performRotation(now);
        scheduleRotation(now);
    }

    private void scheduleRotation(final ZonedDateTime now) {
        final Optional rotateAt = getRotateAt();

        if (rotateAt.isPresent()) {
            // If we we don't need to wait then just set the scheduled delay to 0.
            // If we need to wait a really small amount of time, set the delay to a minimum to avoid sleep thrashing.
            // Otherwise schedule the next rotation at the predicted time.
            // Finally, if we wake-up and there's nothing to rotate we'll just re-apply these rules.

            Duration timeToRotate = Duration.between(now, rotateAt.get());
            if (timeToRotate.isNegative()) {
                timeToRotate = Duration.ZERO;
            } else if (timeToRotate.compareTo(MINIMUM_ROTATION_CHECK_INTERVAL) < 0) {
                timeToRotate = MINIMUM_ROTATION_CHECK_INTERVAL;
            }

            context().system().scheduler().scheduleOnce(
                    timeToRotate,
                    self(),
                    ROTATE_MESSAGE,
                    context().dispatcher(),
                    self());

            _hasRotateScheduled = true;
        }
    }

    /**
     * Generate a Steno log compatible representation.
     *
     * @return Steno log compatible representation.
     */
    @LogValue
    public Object toLogValue() {
        return LogValueMapFactory.builder(this)
                .put("period", _period)
                .put("bucketBuilder", _bucketBuilder)
                .build();
    }

    @Override
    public String toString() {
        return toLogValue().toString();
    }

    private void processRecord(final Record record) {
        // Find an existing bucket for the record
        final Duration timeout = getPeriodTimeout(_period);
        final ZonedDateTime start = getStartTime(record.getTime(), _period);
        Bucket bucket = _bucketsByStart.get(start);

        // Create a new bucket if one does not exist
        if (bucket == null) {
            final Bucket newBucket = _bucketBuilder
                    .setStart(start)
                    .build();

            _bucketsByStart.put(
                    start,
                    newBucket);

            bucket = newBucket;

            final ZonedDateTime expiration = max(ZonedDateTime.now().plus(timeout), start.plus(_period).plus(timeout));

            LOGGER.debug()
                    .setMessage("Created new bucket")
                    .addData("bucket", newBucket)
                    .addData("expiration", expiration)
                    .addData("trigger", record.getId())
                    .log();

            // Index the bucket by its expiration date; the expiration date is always in the future
            _bucketsByExpiration.compute(expiration, (dateTime, buckets) -> {
                if (buckets == null) {
                    buckets = Lists.newArrayList();
                }
                buckets.add(newBucket);
                return buckets;
            });

            // Ensure rotation is scheduled (now that we have data)
            if (!_hasRotateScheduled) {
                scheduleRotation(ZonedDateTime.now());
            }
        } else if (!_hasRotateScheduled) {
            // This is a serious bug!
            LOGGER.error()
                    .setMessage("Rotation not already scheduled while adding to existing bucket")
                    .addData("bucket", bucket)
                    .addData("record", record)
                    .log();

            // But we can cover up the issue by scheduling a rotation
            scheduleRotation(ZonedDateTime.now());
        }

        // Add the record to the _existing_ bucket
        bucket.add(record);
    }

    /* package private */ void performRotation(final ZonedDateTime now) {
        final Map> expiredBucketMap = _bucketsByExpiration.headMap(now);
        final List expiredBuckets = Lists.newArrayList();
        int closedBucketCount = 0;

        // Phase 1: Collect expired buckets
        for (final ZonedDateTime key : expiredBucketMap.keySet()) {
            for (final Bucket bucket : _bucketsByExpiration.remove(key)) {
                expiredBuckets.add(bucket);
            }
        }

        // Phase 2: Close the expired buckets
        for (final Bucket bucket : expiredBuckets) {
            bucket.close();
            _bucketsByStart.remove(bucket.getStart());
            ++closedBucketCount;

            LOGGER.debug()
                    .setMessage("Bucket closed")
                    .addData("periodWorker", this)
                    .addData("bucket", bucket)
                    .addData("now", now)
                    .log();
        }

        LOGGER.debug().setMessage("Rotated").addData("count", closedBucketCount).log();
    }

    /* package private */ Optional getRotateAt() {
        return Optional.ofNullable(_bucketsByExpiration.firstEntry())
                .map(Map.Entry::getKey);
    }

    /* package private */ static Duration getPeriodTimeout(final Duration period) {
        // TODO(vkoskela): Support separate configurable timeouts per period. [MAI-499]
        final Duration timeoutDuration = period.dividedBy(2);
        if (MINIMUM_PERIOD_TIMEOUT.compareTo(timeoutDuration) > 0) {
            return MINIMUM_PERIOD_TIMEOUT;
        }
        if (MAXIMUM_PERIOD_TIMEOUT.compareTo(timeoutDuration) < 0) {
            return MAXIMUM_PERIOD_TIMEOUT;
        }
        return timeoutDuration;
    }

    /* package private */ static ZonedDateTime getStartTime(final ZonedDateTime dateTime, final Duration period) {
        // This effectively uses Jan 1, 1970 at 00:00:00 as the anchor point
        // for non-standard bucket sizes (e.g. 18 min) that do not divide
        // equally into an hour or day. Such use cases are rather uncommon.
        final long periodMillis = period.toMillis();
        final long dateTimeMillis = dateTime.toInstant().toEpochMilli();
        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateTimeMillis - (dateTimeMillis % periodMillis)), ZoneOffset.UTC);
    }

    /* package private */ static ZonedDateTime max(final ZonedDateTime dateTime1, final ZonedDateTime dateTime2) {
        if (dateTime1.isAfter(dateTime2)) {
            return dateTime1;
        }
        return dateTime2;
    }

    private boolean _hasRotateScheduled;

    private final Duration _period;
    private final Bucket.Builder _bucketBuilder;
    private final NavigableMap _bucketsByStart = new TreeMap<>();
    private final NavigableMap> _bucketsByExpiration = new TreeMap<>();

    private static final Logger LOGGER = LoggerFactory.getLogger(PeriodWorker.class);
    private static final Duration MINIMUM_ROTATION_CHECK_INTERVAL = Duration.ofMillis(100);
    private static final Duration MINIMUM_PERIOD_TIMEOUT = Duration.ofSeconds(1);
    private static final Duration MAXIMUM_PERIOD_TIMEOUT = Duration.ofMinutes(10);
    private static final String ROTATE_MESSAGE = "ROTATE_NOW";
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy