StatsTrack.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.zookeeper;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.apache.zookeeper.common.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * a class that represents the stats associated with quotas
 */
public class StatsTrack {

    private static final Logger LOG = LoggerFactory.getLogger(StatsTrack.class);

    private static final String countStr = "count";
    private static final String countHardLimitStr = "countHardLimit";

    private static final String byteStr = "bytes";
    private static final String byteHardLimitStr = "byteHardLimit";

    private final Map<String, Long> stats = new HashMap<>();
    private static final Pattern PAIRS_SEPARATOR = Pattern.compile("[,;]+");

    /**
     * a default constructor for
     * stats
     */
    public StatsTrack() {
        this("");
    }

    /**
     *
     * @param stat the byte[] stat to be initialized with
     */
    public StatsTrack(byte[] stat) {
        // A null payload (jute length -1) must be tolerated here: this is on the
        // transaction-apply/replay path, and new String(null, ...) would NPE,
        // killing the SyncRequestProcessor critical thread and bricking the
        // dataDir just like a malformed value. Treat null as empty/uninitialized.
        this(stat == null ? null : new String(stat, StandardCharsets.UTF_8));
    }

    /**
     * the stat string should be of the form key1str=long,key2str=long,..
     * where either , or ; are valid separators
     * uninitialized values are returned as -1
     * @param stat the stat string to be initialized with
     */
    public StatsTrack(String stat) {
        this.stats.clear();
        if (stat == null || stat.length() == 0) {
            return;
        }
        String[] keyValuePairs = PAIRS_SEPARATOR.split(stat);
        for (String keyValuePair : keyValuePairs) {
            // NOTE: split with the default (limit 0) is intentional and must be
            // kept: toString() emits a compatibility sentinel trailing '=' (e.g.
            // "bytes=5=") that the default split silently drops, recovering "5".
            // Changing the limit would break the round-trip of hard-limit values.
            String[] kv = keyValuePair.split("=");
            if (kv.length < 2 || StringUtils.isEmpty(kv[0])) {
                // Malformed entry (e.g. "x", or "count=" with no value). This
                // data is applied from an already-committed transaction, so
                // throwing here would kill the SyncRequestProcessor critical
                // thread and, because the transaction is durable, make the
                // dataDir unbootable on replay. Skip the entry defensively
                // instead (treated as uninitialized, i.e. -1); updateQuotaStat
                // rewrites the node with a well-formed value, so it self-heals.
                LOG.warn("Ignoring malformed quota stat entry: '{}'", keyValuePair);
                continue;
            }
            String value = StringUtils.isEmpty(kv[1]) ? "-1" : kv[1];
            try {
                this.stats.put(kv[0], Long.parseLong(value));
            } catch (NumberFormatException e) {
                LOG.warn("Ignoring quota stat entry with non-numeric value: '{}'", keyValuePair);
            }
        }
    }

    /**
     * Strictly validate that {@code data} is a well-formed stats/limits value,
     * i.e. a (possibly empty) list of {@code key=long} pairs separated by
     * {@code ,} or {@code ;}. Unlike the constructor, this performs no repair ���
     * it is used at request-preparation time to reject malformed writes to the
     * quota {@code zookeeper_stats}/{@code zookeeper_limits} nodes before they
     * are ever committed, so clients get a clear error and the quota bookkeeping
     * never ingests garbage.
     *
     * @param data the candidate node data (UTF-8 encoded); empty is accepted
     *             (treated as uninitialized), null is rejected
     * @return true if the data parses cleanly as StatsTrack content
     */
    public static boolean isValidStatsData(byte[] data) {
        if (data == null) {
            // null is never legitimate for a stats/limits node and NPEs the
            // byte[] constructor on the apply path ��� reject it at prep time.
            return false;
        }
        if (data.length == 0) {
            return true;
        }
        String stat = new String(data, StandardCharsets.UTF_8);
        String[] keyValuePairs = PAIRS_SEPARATOR.split(stat);
        for (String keyValuePair : keyValuePairs) {
            // Same tokenization as the constructor so the compatibility sentinel
            // trailing '=' (e.g. "bytes=5=") is accepted, not rejected.
            String[] kv = keyValuePair.split("=");
            if (kv.length < 2 || StringUtils.isEmpty(kv[0])) {
                return false;
            }
            if (!StringUtils.isEmpty(kv[1])) {
                try {
                    Long.parseLong(kv[1]);
                } catch (NumberFormatException e) {
                    return false;
                }
            }
        }
        return true;
    }


    /**
     * get the count of nodes allowed as part of quota
     *
     * @return the count as part of this string
     */
    public long getCount() {
        return getValue(countStr);
    }

    /**
     * set the count for this stat tracker.
     *
     * @param count
     *            the count to set with
     */
    public void setCount(long count) {
        setValue(countStr, count);
    }

    /**
     * get the count of nodes allowed as part of quota (hard limit)
     *
     * @return the count as part of this string
     */
    public long getCountHardLimit() {
        return getValue(countHardLimitStr);
    }

    /**
     * set the count hard limit
     *
     * @param count the count limit to set
     */
    public void setCountHardLimit(long count) {
        setValue(countHardLimitStr, count);
    }

    /**
     * get the count of bytes allowed as part of quota
     *
     * @return the bytes as part of this string
     */
    public long getBytes() {
        return getValue(byteStr);
    }

    /**
     * set the bytes for this stat tracker.
     *
     * @param bytes
     *            the bytes to set with
     */
    public void setBytes(long bytes) {
        setValue(byteStr, bytes);
    }

    /**
     * get the count of bytes allowed as part of quota (hard limit)
     *
     * @return the bytes as part of this string
     */
    public long getByteHardLimit() {
        return getValue(byteHardLimitStr);
    }

    /**
     * set the byte hard limit
     *
     * @param bytes the byte limit to set
     */
    public void setByteHardLimit(long bytes) {
        setValue(byteHardLimitStr, bytes);
    }

    /**
     * get helper to lookup a given key
     *
     * @param key the key to lookup
     * @return key's value or -1 if it doesn't exist
     */
    private long getValue(String key) {
        Long val = this.stats.get(key);
        return val == null ? -1 : val.longValue();
    }

    /**
     * set helper to set the value for the specified key
     *
     * @param key   the key to set
     * @param value the value to set
     */
    private void setValue(String key, long value) {
        this.stats.put(key, value);
    }

    /*
     * returns the string that maps to this stat tracking.
     *
     * Builds a string of the form
     * "count=4,bytes=5=;countHardLimit=10;byteHardLimit=10"
     *
     * This string is slightly hacky to preserve compatibility with 3.4.3 and
     * older parser. In particular, count must be first, bytes must be second,
     * all new fields must use a separator that is not a "," (so, ";"), and the
     * seemingly spurious "=" after the bytes field is essential to allowing
     * it to be parseable by the old parsing code.
     */
    @Override
    public String toString() {
        StringBuilder buf = new StringBuilder();
        ArrayList<String> keys = new ArrayList<>(stats.keySet());

        // Special handling for count=,byte= to enforce them coming first
        // for backwards compatibility
        keys.remove(countStr);
        keys.remove(byteStr);
        buf.append(countStr);
        buf.append("=");
        buf.append(getCount());
        buf.append(",");
        buf.append(byteStr);
        buf.append("=");
        buf.append(getBytes());
        if (!keys.isEmpty()) {
            // Add extra = to trick old parsing code so it will ignore new flags
            buf.append("=");
            Collections.sort(keys);
            for (String key : keys) {
                buf.append(";");
                buf.append(key);
                buf.append("=");
                buf.append(stats.get(key));
            }
        }
        return buf.toString();
    }

    public byte[] getStatsBytes() {
        return toString().getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final StatsTrack that = (StatsTrack) o;
        return Objects.equals(stats, that.stats);
    }

    @Override
    public int hashCode() {
        return Objects.hash(stats);
    }
}