// This file is part of OpenTSDB.
// Copyright (C) 2010-2012 The OpenTSDB Authors.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 2.1 of the License, or (at your
// option) any later version. This program is distributed in the hope that it
// will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details. You should have received a copy
// of the GNU Lesser General Public License along with this program. If not,
// see <http://www.gnu.org/licenses/>.
package net.opentsdb.uid;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.hbase.async.Bytes;
import org.hbase.async.DeleteRequest;
import org.hbase.async.GetRequest;
import org.hbase.async.HBaseClient;
import org.hbase.async.HBaseException;
import org.hbase.async.KeyValue;
import org.hbase.async.PutRequest;
import org.hbase.async.RowLock;
import org.hbase.async.RowLockRequest;
import org.hbase.async.Scanner;
/**
* Thread-safe implementation of the {@link UniqueIdInterface}.
* <p>
* Don't attempt to use {@code equals()} or {@code hashCode()} on
* this class.
* @see UniqueIdInterface
*/
public final class UniqueId implements UniqueIdInterface {
private static final Logger LOG = LoggerFactory.getLogger(UniqueId.class);
/** Charset used to convert Strings to byte arrays and back. */
private static final Charset CHARSET = Charset.forName("ISO-8859-1");
/** The single column family used by this class. */
private static final byte[] ID_FAMILY = toBytes("id");
/** The single column family used by this class. */
private static final byte[] NAME_FAMILY = toBytes("name");
/** Row key of the special row used to track the max ID already assigned. */
private static final byte[] MAXID_ROW = { 0 };
/** How many time do we try to assign an ID before giving up. */
private static final short MAX_ATTEMPTS_ASSIGN_ID = 3;
/** How many time do we try to apply an edit before giving up. */
private static final short MAX_ATTEMPTS_PUT = 6;
/** Initial delay in ms for exponential backoff to retry failed RPCs. */
private static final short INITIAL_EXP_BACKOFF_DELAY = 800;
/** Maximum number of results to return in suggest(). */
private static final short MAX_SUGGESTIONS = 25;
/** HBase client to use. */
private final HBaseClient client;
/** Table where IDs are stored. */
private final byte[] table;
/** The kind of UniqueId, used as the column qualifier. */
private final byte[] kind;
/** Number of bytes on which each ID is encoded. */
private final short idWidth;
/** Cache for forward mappings (name to ID). */
private final ConcurrentHashMap<String, byte[]> nameCache =
new ConcurrentHashMap<String, byte[]>();
/** Cache for backward mappings (ID to name).
* The ID in the key is a byte[] converted to a String to be Comparable. */
private final ConcurrentHashMap<String, String> idCache =
new ConcurrentHashMap<String, String>();
/** Number of times we avoided reading from HBase thanks to the cache. */
private volatile int cacheHits;
/** Number of times we had to read from HBase and populate the cache. */
private volatile int cacheMisses;
/**
* Constructor.
* @param client The HBase client to use.
* @param table The name of the HBase table to use.
* @param kind The kind of Unique ID this instance will deal with.
* @param width The number of bytes on which Unique IDs should be encoded.
* @throws IllegalArgumentException if width is negative or too small/large
* or if kind is an empty string.
*/
public UniqueId(final HBaseClient client, final byte[] table, final String kind,
final int width) {
this.client = client;
this.table = table;
if (kind.isEmpty()) {
throw new IllegalArgumentException("Empty string as 'kind' argument!");
}
this.kind = toBytes(kind);
if (width < 1 || width > 8) {
throw new IllegalArgumentException("Invalid width: " + width);
}
this.idWidth = (short) width;
}
/** The number of times we avoided reading from HBase thanks to the cache. */
public int cacheHits() {
return cacheHits;
}
/** The number of times we had to read from HBase and populate the cache. */
public int cacheMisses() {
return cacheMisses;
}
/** Returns the number of elements stored in the internal cache. */
public int cacheSize() {
return nameCache.size() + idCache.size();
}
public String kind() {
return fromBytes(kind);
}
public short width() {
return idWidth;
}
/**
* Causes this instance to discard all its in-memory caches.
* @since 1.1
*/
public void dropCaches() {
nameCache.clear();
idCache.clear();
}
public String getName(final byte[] id) throws NoSuchUniqueId, HBaseException {
if (id.length != idWidth) {
throw new IllegalArgumentException("Wrong id.length = " + id.length
+ " which is != " + idWidth
+ " required for '" + kind() + '\'');
}
String name = getNameFromCache(id);
if (name != null) {
cacheHits++;
} else {
cacheMisses++;
name = getNameFromHBase(id);
if (name == null) {
throw new NoSuchUniqueId(kind(), id);
}
addNameToCache(id, name);
addIdToCache(name, id);
}
return name;
}
private String getNameFromCache(final byte[] id) {
return idCache.get(fromBytes(id));
}
private String getNameFromHBase(final byte[] id) throws HBaseException {
final byte[] name = hbaseGet(id, NAME_FAMILY);
return name == null ? null : fromBytes(name);
}
private void addNameToCache(final byte[] id, final String name) {
final String key = fromBytes(id);
String found = idCache.get(key);
if (found == null) {
found = idCache.putIfAbsent(key, name);
}
if (found != null && !found.equals(name)) {
throw new IllegalStateException("id=" + Arrays.toString(id) + " => name="
+ name + ", already mapped to " + found);
}
}
public byte[] getId(final String name) throws NoSuchUniqueName, HBaseException {
byte[] id = getIdFromCache(name);
if (id != null) {
cacheHits++;
} else {
cacheMisses++;
id = getIdFromHBase(name);
if (id == null) {
throw new NoSuchUniqueName(kind(), name);
}
if (id.length != idWidth) {
throw new IllegalStateException("Found id.length = " + id.length
+ " which is != " + idWidth
+ " required for '" + kind() + '\'');
}
addIdToCache(name, id);
addNameToCache(id, name);
}
return id;
}
private byte[] getIdFromCache(final String name) {
return nameCache.get(name);
}
private byte[] getIdFromHBase(final String name) throws HBaseException {
return hbaseGet(toBytes(name), ID_FAMILY);
}
private void addIdToCache(final String name, final byte[] id) {
byte[] found = nameCache.get(name);
if (found == null) {
found = nameCache.putIfAbsent(name,
// Must make a defensive copy to be immune
// to any changes the caller may do on the
// array later on.
Arrays.copyOf(id, id.length));
}
if (found != null && !Arrays.equals(found, id)) {
throw new IllegalStateException("name=" + name + " => id="
+ Arrays.toString(id) + ", already mapped to "
+ Arrays.toString(found));
}
}
public byte[] getOrCreateId(String name) throws HBaseException {
short attempt = MAX_ATTEMPTS_ASSIGN_ID;
HBaseException hbe = null;
while (attempt-- > 0) {
try {
return getId(name);
} catch (NoSuchUniqueName e) {
LOG.info("Creating an ID for kind='" + kind()
+ "' name='" + name + '\'');
}
// The dance to assign an ID.
RowLock lock;
try {
lock = getLock();
} catch (HBaseException e) {
try {
Thread.sleep(61000 / MAX_ATTEMPTS_ASSIGN_ID);
} catch (InterruptedException ie) {
break; // We've been asked to stop here, let's bail out.
}
hbe = e;
continue;
}
if (lock == null) { // Should not happen.
LOG.error("WTF, got a null pointer as a RowLock!");
continue;
}
// We now have hbase.regionserver.lease.period ms to complete the loop.
try {
// Verify that the row still doesn't exist (to avoid re-creating it if
// it got created before we acquired the lock due to a race condition).
try {
final byte[] id = getId(name);
LOG.info("Race condition, found ID for kind='" + kind()
+ "' name='" + name + '\'');
return id;
} catch (NoSuchUniqueName e) {
// OK, the row still doesn't exist, let's create it now.
}
// Assign an ID.
long id; // The ID.
byte row[]; // The same ID, as a byte array.
try {
// We want to send an ICV with our explicit RowLock, but HBase's RPC
// interface doesn't expose this interface. Since an ICV would
// attempt to lock the row again, and we already locked it, we can't
// use ICV here, we have to do it manually while we hold the RowLock.
// To be fixed by HBASE-2292.
{ // HACK HACK HACK
{
final byte[] current_maxid = hbaseGet(MAXID_ROW, ID_FAMILY, lock);
if (current_maxid != null) {
if (current_maxid.length == 8) {
id = Bytes.getLong(current_maxid) + 1;
} else {
throw new IllegalStateException("invalid current_maxid="
+ Arrays.toString(current_maxid));
}
} else {
id = 1;
}
row = Bytes.fromLong(id);
}
final PutRequest update_maxid = new PutRequest(
table, MAXID_ROW, ID_FAMILY, kind, row, lock);
hbasePutWithRetry(update_maxid, MAX_ATTEMPTS_PUT,
INITIAL_EXP_BACKOFF_DELAY);
} // end HACK HACK HACK.
LOG.info("Got ID=" + id
+ " for kind='" + kind() + "' name='" + name + "'");
// row.length should actually be 8.
if (row.length < idWidth) {
throw new IllegalStateException("OMG, row.length = " + row.length
+ " which is less than " + idWidth
+ " for id=" + id
+ " row=" + Arrays.toString(row));
}
// Verify that we're going to drop bytes that are 0.
for (int i = 0; i < row.length - idWidth; i++) {
if (row[i] != 0) {
final String message = "All Unique IDs for " + kind()
+ " on " + idWidth + " bytes are already assigned!";
LOG.error("OMG " + message);
throw new IllegalStateException(message);
}
}
// Shrink the ID on the requested number of bytes.
row = Arrays.copyOfRange(row, row.length - idWidth, row.length);
} catch (HBaseException e) {
LOG.error("Failed to assign an ID, ICV on row="
+ Arrays.toString(MAXID_ROW) + " column='" +
fromBytes(ID_FAMILY) + ':' + kind() + '\'', e);
hbe = e;
continue;
} catch (IllegalStateException e) {
throw e; // To avoid handling this exception in the next `catch'.
} catch (Exception e) {
LOG.error("WTF? Unexpected exception type when assigning an ID,"
+ " ICV on row=" + Arrays.toString(MAXID_ROW) + " column='"
+ fromBytes(ID_FAMILY) + ':' + kind() + '\'', e);
continue;
}
// If we die before the next PutRequest succeeds, we just waste an ID.
// Create the reverse mapping first, so that if we die before creating
// the forward mapping we don't run the risk of "publishing" a
// partially assigned ID. The reverse mapping on its own is harmless
// but the forward mapping without reverse mapping is bad.
try {
final PutRequest reverse_mapping = new PutRequest(
table, row, NAME_FAMILY, kind, toBytes(name));
hbasePutWithRetry(reverse_mapping, MAX_ATTEMPTS_PUT,
INITIAL_EXP_BACKOFF_DELAY);
} catch (HBaseException e) {
LOG.error("Failed to Put reverse mapping! ID leaked: " + id, e);
hbe = e;
continue;
}
// Now create the forward mapping.
try {
final PutRequest forward_mapping = new PutRequest(
table, toBytes(name), ID_FAMILY, kind, row);
hbasePutWithRetry(forward_mapping, MAX_ATTEMPTS_PUT,
INITIAL_EXP_BACKOFF_DELAY);
} catch (HBaseException e) {
LOG.error("Failed to Put forward mapping! ID leaked: " + id, e);
hbe = e;
continue;
}
addIdToCache(name, row);
addNameToCache(row, name);
return row;
} finally {
unlock(lock);
}
}
if (hbe == null) {
throw new IllegalStateException("Should never happen!");
}
LOG.error("Failed to assign an ID for kind='" + kind()
+ "' name='" + name + "'", hbe);
throw hbe;
}
/**
* Attempts to find suggestions of names given a search term.
* @param search The search term (possibly empty).
* @return A list of known valid names that have UIDs that sort of match
* the search term. If the search term is empty, returns the first few
* terms.
* @throws HBaseException if there was a problem getting suggestions from
* HBase.
*/
public List<String> suggest(final String search) throws HBaseException {
// TODO(tsuna): Add caching to try to avoid re-scanning the same thing.
final Scanner scanner = getSuggestScanner(search);
final LinkedList<String> suggestions = new LinkedList<String>();
try {
ArrayList<ArrayList<KeyValue>> rows;
while ((short) suggestions.size() < MAX_SUGGESTIONS
&& (rows = scanner.nextRows().joinUninterruptibly()) != null) {
for (final ArrayList<KeyValue> row : rows) {
if (row.size() != 1) {
LOG.error("WTF shouldn't happen! Scanner " + scanner + " returned"
+ " a row that doesn't have exactly 1 KeyValue: " + row);
if (row.isEmpty()) {
continue;
}
}
final byte[] key = row.get(0).key();
final String name = fromBytes(key);
final byte[] id = row.get(0).value();
final byte[] cached_id = nameCache.get(name);
if (cached_id == null) {
addIdToCache(name, id);
addNameToCache(id, name);
} else if (!Arrays.equals(id, cached_id)) {
throw new IllegalStateException("WTF? For kind=" + kind()
+ " name=" + name + ", we have id=" + Arrays.toString(cached_id)
+ " in cache, but just scanned id=" + Arrays.toString(id));
}
suggestions.add(name);
}
}
} catch (HBaseException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Should never be here", e);
} finally {
//scanner.close();
}
return suggestions;
}
/**
* Reassigns the UID to a different name (non-atomic).
* <p>
* Whatever was the UID of {@code oldname} will be given to {@code newname}.
* {@code oldname} will no longer be assigned a UID.
* <p>
* Beware that the assignment change is <b>not atommic</b>. If two threads
* or processes attempt to rename the same UID differently, the result is
* unspecified and might even be inconsistent. This API is only here for
* administrative purposes, not for normal programmatic interactions.
* @param oldname The old name to rename.
* @param newname The new name.
* @throws NoSuchUniqueName if {@code oldname} wasn't assigned.
* @throws IllegalArgumentException if {@code newname} was already assigned.
* @throws HBaseException if there was a problem with HBase while trying to
* update the mapping.
*/
public void rename(final String oldname, final String newname) {
final byte[] row = getId(oldname);
{
byte[] id = null;
try {
id = getId(newname);
} catch (NoSuchUniqueName e) {
// OK, we don't want the new name to be assigned.
}
if (id != null) {
throw new IllegalArgumentException("When trying rename(\"" + oldname
+ "\", \"" + newname + "\") on " + this + ": new name already"
+ " assigned ID=" + Arrays.toString(id));
}
}
final byte[] newnameb = toBytes(newname);
// Update the reverse mapping first, so that if we die before updating
// the forward mapping we don't run the risk of "publishing" a
// partially assigned ID. The reverse mapping on its own is harmless
// but the forward mapping without reverse mapping is bad.
try {
final PutRequest reverse_mapping = new PutRequest(
table, row, NAME_FAMILY, kind, newnameb);
hbasePutWithRetry(reverse_mapping, MAX_ATTEMPTS_PUT,
INITIAL_EXP_BACKOFF_DELAY);
} catch (HBaseException e) {
LOG.error("When trying rename(\"" + oldname
+ "\", \"" + newname + "\") on " + this + ": Failed to update reverse"
+ " mapping for ID=" + Arrays.toString(row), e);
throw e;
}
// Now create the new forward mapping.
try {
final PutRequest forward_mapping = new PutRequest(
table, newnameb, ID_FAMILY, kind, row);
hbasePutWithRetry(forward_mapping, MAX_ATTEMPTS_PUT,
INITIAL_EXP_BACKOFF_DELAY);
} catch (HBaseException e) {
LOG.error("When trying rename(\"" + oldname
+ "\", \"" + newname + "\") on " + this + ": Failed to create the"
+ " new forward mapping with ID=" + Arrays.toString(row), e);
throw e;
}
// Update cache.
addIdToCache(newname, row); // add new name -> ID
idCache.put(fromBytes(row), newname); // update ID -> new name
nameCache.remove(oldname); // remove old name -> ID
// Delete the old forward mapping.
try {
final DeleteRequest old_forward_mapping = new DeleteRequest(
table, toBytes(oldname), ID_FAMILY, kind);
client.delete(old_forward_mapping).joinUninterruptibly();
} catch (HBaseException e) {
LOG.error("When trying rename(\"" + oldname
+ "\", \"" + newname + "\") on " + this + ": Failed to remove the"
+ " old forward mapping for ID=" + Arrays.toString(row), e);
throw e;
} catch (Exception e) {
final String msg = "Unexpected exception when trying rename(\"" + oldname
+ "\", \"" + newname + "\") on " + this + ": Failed to remove the"
+ " old forward mapping for ID=" + Arrays.toString(row);
LOG.error("WTF? " + msg, e);
throw new RuntimeException(msg, e);
}
// Success!
}
/** The start row to scan on empty search strings. `!' = first ASCII char. */
private static final byte[] START_ROW = new byte[] { '!' };
/** The end row to scan on empty search strings. `~' = last ASCII char. */
private static final byte[] END_ROW = new byte[] { '~' };
/**
* Creates a scanner that scans the right range of rows for suggestions.
*/
private Scanner getSuggestScanner(final String search) {
final byte[] start_row;
final byte[] end_row;
if (search.isEmpty()) {
start_row = START_ROW;
end_row = END_ROW;
} else {
start_row = toBytes(search);
end_row = Arrays.copyOf(start_row, start_row.length);
end_row[start_row.length - 1]++;
}
final Scanner scanner = client.newScanner(table);
scanner.setStartKey(start_row);
scanner.setStopKey(end_row);
scanner.setFamily(ID_FAMILY);
scanner.setQualifier(kind);
scanner.setMaxNumRows(MAX_SUGGESTIONS);
return scanner;
}
/** Gets an exclusive lock for on the table using the MAXID_ROW.
* The lock expires after hbase.regionserver.lease.period ms
* (default = 60000)
* @throws HBaseException if the row lock couldn't be acquired.
*/
private RowLock getLock() throws HBaseException {
try {
return client.lockRow(new RowLockRequest(table, MAXID_ROW)).joinUninterruptibly();
} catch (HBaseException e) {
LOG.warn("Failed to lock the `MAXID_ROW' row", e);
throw e;
} catch (Exception e) {
throw new RuntimeException("Should never be here", e);
}
}
/** Releases the lock passed in argument. */
private void unlock(final RowLock lock) {
try {
client.unlockRow(lock);
} catch (HBaseException e) {
LOG.error("Error while releasing the lock on row `MAXID_ROW'", e);
}
}
/** Returns the cell of the specified row, using family:kind. */
private byte[] hbaseGet(final byte[] row, final byte[] family) throws HBaseException {
return hbaseGet(row, family, null);
}
/** Returns the cell of the specified row key, using family:kind. */
private byte[] hbaseGet(final byte[] key, final byte[] family,
final RowLock lock) throws HBaseException {
final GetRequest get = new GetRequest(table, key);
if (lock != null) {
get.withRowLock(lock);
}
get.family(family).qualifier(kind);
try {
final ArrayList<KeyValue> row = client.get(get).joinUninterruptibly();
if (row == null || row.isEmpty()) {
return null;
}
return row.get(0).value();
} catch (HBaseException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Should never be here", e);
}
}
/**
* Attempts to run the PutRequest given in argument, retrying if needed.
*
* Puts are synchronized.
*
* @param put The PutRequest to execute.
* @param attempts The maximum number of attempts.
* @param wait The initial amount of time in ms to sleep for after a
* failure. This amount is doubled after each failed attempt.
* @throws HBaseException if all the attempts have failed. This exception
* will be the exception of the last attempt.
*/
private void hbasePutWithRetry(final PutRequest put, short attempts, short wait)
throws HBaseException {
put.setBufferable(false); // TODO(tsuna): Remove once this code is async.
while (attempts-- > 0) {
try {
client.put(put).joinUninterruptibly();
return;
} catch (HBaseException e) {
if (attempts > 0) {
LOG.error("Put failed, attempts left=" + attempts
+ " (retrying in " + wait + " ms), put=" + put, e);
try {
Thread.sleep(wait);
} catch (InterruptedException ie) {
throw new RuntimeException("interrupted", ie);
}
wait *= 2;
} else {
throw e;
}
} catch (Exception e) {
LOG.error("WTF? Unexpected exception type, put=" + put, e);
}
}
throw new IllegalStateException("This code should never be reached!");
}
private static byte[] toBytes(final String s) {
return s.getBytes(CHARSET);
}
private static String fromBytes(final byte[] b) {
return new String(b, CHARSET);
}
/** Returns a human readable string representation of the object. */
public String toString() {
return "UniqueId(" + fromBytes(table) + ", " + kind() + ", " + idWidth + ")";
}
}