// 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.utils;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.TimeZone;
import net.opentsdb.core.Tags;
/**
* Utility class that provides helpers for dealing with dates and timestamps.
* In particular, this class handles parsing relative or human readable
* date/time strings provided in queries.
* @since 2.0
*/
public class DateTime {
/** ID of the UTC timezone */
public static final String UTC_ID = "UTC";
/**
* Immutable cache mapping a timezone name to its object.
* We do this because the JDK's TimeZone class was implemented by retards,
* and it's synchronized, going through a huge pile of code, and allocating
* new objects all the time. And to make things even better, if you ask for
* a TimeZone that doesn't exist, it returns GMT! It is thus impractical to
* tell if the timezone name was valid or not. JDK_brain_damage++;
* Note: caching everything wastes a few KB on RAM (34KB on my system with
* 611 timezones -- each instance is 56 bytes with the Sun JDK).
*/
public static final HashMap<String, TimeZone> timezones;
static {
final String[] tzs = TimeZone.getAvailableIDs();
timezones = new HashMap<String, TimeZone>(tzs.length);
for (final String tz : tzs) {
timezones.put(tz, TimeZone.getTimeZone(tz));
}
}
/**
* Attempts to parse a timestamp from a given string
* Formats accepted are:
* <ul>
* <li>Relative: {@code 5m-ago}, {@code 1h-ago}, etc. See
* {@link #parseDuration}</li>
* <li>Absolute human readable dates:
* <ul><li>"yyyy/MM/dd-HH:mm:ss"</li>
* <li>"yyyy/MM/dd HH:mm:ss"</li>
* <li>"yyyy/MM/dd-HH:mm"</li>
* <li>"yyyy/MM/dd HH:mm"</li>
* <li>"yyyy/MM/dd"</li></ul></li>
* <li>Unix Timestamp in seconds or milliseconds:
* <ul><li>1355961600</li>
* <li>1355961600000</li>
* <li>1355961600.000</li></ul></li>
* </ul>
* @param datetime The string to parse a value for
* @return A Unix epoch timestamp in milliseconds
* @throws NullPointerException if the timestamp is null
* @throws IllegalArgumentException if the request was malformed
*/
public static final long parseDateTimeString(final String datetime,
final String tz) {
if (datetime == null || datetime.isEmpty())
return -1;
if (datetime.matches("^[0-9]+ms$")) {
return Tags.parseLong(datetime.replaceFirst("^([0-9]+)(ms)$", "$1"));
}
if (datetime.toLowerCase().equals("now")) {
return System.currentTimeMillis();
}
if (datetime.toLowerCase().endsWith("-ago")) {
long interval = DateTime.parseDuration(
datetime.substring(0, datetime.length() - 4));
return System.currentTimeMillis() - interval;
}
if (datetime.contains("/") || datetime.contains(":")) {
try {
SimpleDateFormat fmt = null;
switch (datetime.length()) {
// these were pulled from cliQuery but don't work as intended since
// they assume a date of 1970/01/01. Can be fixed but may not be worth
// it
// case 5:
// fmt = new SimpleDateFormat("HH:mm");
// break;
// case 8:
// fmt = new SimpleDateFormat("HH:mm:ss");
// break;
case 10:
fmt = new SimpleDateFormat("yyyy/MM/dd");
break;
case 16:
if (datetime.contains("-"))
fmt = new SimpleDateFormat("yyyy/MM/dd-HH:mm");
else
fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm");
break;
case 19:
if (datetime.contains("-"))
fmt = new SimpleDateFormat("yyyy/MM/dd-HH:mm:ss");
else
fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
break;
default:
// todo - deal with internationalization, other time formats
throw new IllegalArgumentException("Invalid absolute date: "
+ datetime);
}
if (tz != null && !tz.isEmpty())
setTimeZone(fmt, tz);
return fmt.parse(datetime).getTime();
} catch (ParseException e) {
throw new IllegalArgumentException("Invalid date: " + datetime
+ ". " + e.getMessage());
}
} else {
try {
long time;
final boolean contains_dot = datetime.contains(".");
// [0-9]{10} ten digits
// \\. a dot
// [0-9]{1,3} one to three digits
final boolean valid_dotted_ms =
datetime.matches("^[0-9]{10}\\.[0-9]{1,3}$");
if (contains_dot) {
if (!valid_dotted_ms) {
throw new IllegalArgumentException("Invalid time: " + datetime
+ ". Millisecond timestamps must be in the format "
+ "<seconds>.<ms> where the milliseconds are limited to 3 digits");
}
time = Tags.parseLong(datetime.replace(".", ""));
} else {
time = Tags.parseLong(datetime);
}
if (time < 0) {
throw new IllegalArgumentException("Invalid time: " + datetime
+ ". Negative timestamps are not supported.");
}
// this is a nasty hack to determine if the incoming request is
// in seconds or milliseconds. This will work until November 2286
if (datetime.length() <= 10) {
time *= 1000;
}
return time;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid time: " + datetime
+ ". " + e.getMessage());
}
}
}
/**
* Parses a human-readable duration (e.g, "10m", "3h", "14d") into seconds.
* <p>
* Formats supported:<ul>
* <li>{@code ms}: milliseconds</li>
* <li>{@code s}: seconds</li>
* <li>{@code m}: minutes</li>
* <li>{@code h}: hours</li>
* <li>{@code d}: days</li>
* <li>{@code w}: weeks</li>
* <li>{@code n}: month (30 days)</li>
* <li>{@code y}: years (365 days)</li></ul>
* @param duration The human-readable duration to parse.
* @return A strictly positive number of milliseconds.
* @throws IllegalArgumentException if the interval was malformed.
*/
public static final long parseDuration(final String duration) {
long interval;
long multiplier;
double temp;
int unit = 0;
while (Character.isDigit(duration.charAt(unit))) {
unit++;
if (unit >= duration.length()) {
throw new IllegalArgumentException("Invalid duration, must have an "
+ "integer and unit: " + duration);
}
}
try {
interval = Long.parseLong(duration.substring(0, unit));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid duration (number): " + duration);
}
if (interval <= 0) {
throw new IllegalArgumentException("Zero or negative duration: " + duration);
}
switch (duration.toLowerCase().charAt(duration.length() - 1)) {
case 's':
if (duration.charAt(duration.length() - 2) == 'm') {
return interval;
}
multiplier = 1; break; // seconds
case 'm': multiplier = 60; break; // minutes
case 'h': multiplier = 3600; break; // hours
case 'd': multiplier = 3600 * 24; break; // days
case 'w': multiplier = 3600 * 24 * 7; break; // weeks
case 'n': multiplier = 3600 * 24 * 30; break; // month (average)
case 'y': multiplier = 3600 * 24 * 365; break; // years (screw leap years)
default: throw new IllegalArgumentException("Invalid duration (suffix): " + duration);
}
multiplier *= 1000;
temp = (double)interval * multiplier;
if (temp > Long.MAX_VALUE) {
throw new IllegalArgumentException("Duration must be < Long.MAX_VALUE ms: " + duration);
}
return interval * multiplier;
}
/**
* Returns whether or not a date is specified in a relative fashion.
* <p>
* A date is specified in a relative fashion if it ends in "-ago",
* e.g. {@code 1d-ago} is the same as {@code 24h-ago}.
* @param value The value to parse
* @return {@code true} if the parameter is passed and is a relative date.
* Note the method doesn't attempt to validate the relative date. So this
* function can return true on something that looks like a relative date,
* but is actually invalid once we really try to parse it.
* @throws NullPointerException if the value is null
*/
public static boolean isRelativeDate(final String value) {
return value.toLowerCase().endsWith("-ago");
}
/**
* Applies the given timezone to the given date format.
* @param fmt Date format to apply the timezone to.
* @param tzname Name of the timezone, or {@code null} in which case this
* function is a no-op.
* @throws IllegalArgumentException if tzname isn't a valid timezone name.
* @throws NullPointerException if the format is null
*/
public static void setTimeZone(final SimpleDateFormat fmt,
final String tzname) {
if (tzname == null) {
return; // Use the default timezone.
}
final TimeZone tz = DateTime.timezones.get(tzname);
if (tz != null) {
fmt.setTimeZone(tz);
} else {
throw new IllegalArgumentException("Invalid timezone name: " + tzname);
}
}
/**
* Sets the default timezone for this running OpenTSDB instance
* <p>
* <b>WARNING</b> If OpenTSDB is used with a Security Manager, setting the default
* timezone only works for the running thread. Otherwise it will work for the
* entire application.
* <p>
* @param tzname Name of the timezone to use
* @throws IllegalArgumentException if tzname isn't a valid timezone name
*/
public static void setDefaultTimezone(final String tzname) {
final TimeZone tz = DateTime.timezones.get(tzname);
if (tz != null) {
TimeZone.setDefault(tz);
} else {
throw new IllegalArgumentException("Invalid timezone name: " + tzname);
}
}
/**
* Pass through to {@link System.currentTimeMillis} for use in classes to
* make unit testing easier. Mocking System.class is a bad idea in general
* so placing this here and mocking DateTime.class is MUCH cleaner.
* @return The current epoch time in milliseconds
* @since 2.1
*/
public static long currentTimeMillis() {
return System.currentTimeMillis();
}
/**
* Pass through to {@link System.nanoTime} for use in classes to
* make unit testing easier. Mocking System.class is a bad idea in general
* so placing this here and mocking DateTime.class is MUCH cleaner.
* @return The current epoch time in milliseconds
* @since 2.2
*/
public static long nanoTime() {
return System.nanoTime();
}
/**
* Converts the long nanosecond value to a double in milliseconds
* @param ts The timestamp or value in nanoseconds
* @return The timestamp in milliseconds
* @since 2.2
*/
public static double msFromNano(final long ts) {
return (double)ts / 1000000;
}
/**
* Calculates the difference between two values and returns the time in
* milliseconds as a double.
* @param end The end timestamp
* @param start The start timestamp
* @return The value in milliseconds
* @throws IllegalArgumentException if end is less than start
* @since 2.2
*/
public static double msFromNanoDiff(final long end, final long start) {
if (end < start) {
throw new IllegalArgumentException("End (" + end + ") cannot be less "
+ "than start (" + start + ")");
}
return ((double) end - (double) start) / 1000000;
}
/**
* Returns a calendar set to the previous interval time based on the
* units and UTC the timezone. This allows for snapping to day, week,
* monthly, etc. boundaries.
* NOTE: It uses a calendar for snapping so isn't as efficient as a simple
* modulo calculation.
* NOTE: For intervals that don't nicely divide into their given unit (e.g.
* a 23s interval where 60 seconds is not divisible by 23) the base time may
* start at the top of the day (for ms and s) or from Unix epoch 0. In the
* latter case, setting up the base timestamp may be slow if the caller does
* something silly like "23m" where we iterate 23 minutes at a time from 0
* till we find the proper timestamp.
* TODO - There is likely a better way to do all of this
* @param ts The timestamp to find an interval for, in milliseconds as
* a Unix epoch.
* @param interval The interval as a measure of units.
* @param unit The unit. This must cast to a Calendar time unit.
* @return A calendar set to the timestamp aligned to the proper interval
* before the given ts
* @throws IllegalArgumentException if the timestamp is negative, if the
* interval is less than 1 or the unit is unrecognized.
* @since 2.3
*/
public static Calendar previousInterval(final long ts, final int interval,
final int unit) {
return previousInterval(ts, interval, unit, null);
}
/**
* Returns a calendar set to the previous interval time based on the
* units and timezone. This allows for snapping to day, week, monthly, etc.
* boundaries.
* NOTE: It uses a calendar for snapping so isn't as efficient as a simple
* modulo calculation.
* NOTE: For intervals that don't nicely divide into their given unit (e.g.
* a 23s interval where 60 seconds is not divisible by 23) the base time may
* start at the top of the day (for ms and s) or from Unix epoch 0. In the
* latter case, setting up the base timestamp may be slow if the caller does
* something silly like "23m" where we iterate 23 minutes at a time from 0
* till we find the proper timestamp.
* TODO - There is likely a better way to do all of this
* @param ts The timestamp to find an interval for, in milliseconds as
* a Unix epoch.
* @param interval The interval as a measure of units.
* @param unit The unit. This must cast to a Calendar time unit.
* @param tz An optional timezone.
* @return A calendar set to the timestamp aligned to the proper interval
* before the given ts
* @throws IllegalArgumentException if the timestamp is negative, if the
* interval is less than 1 or the unit is unrecognized.
* @since 2.3
*/
public static Calendar previousInterval(final long ts, final int interval,
final int unit, final TimeZone tz) {
if (ts < 0) {
throw new IllegalArgumentException("Timestamp cannot be less than zero");
}
if (interval < 1) {
throw new IllegalArgumentException("Interval must be greater than zero");
}
int unit_override = unit;
int interval_override = interval;
final Calendar calendar;
if (tz == null) {
calendar = Calendar.getInstance(timezones.get(UTC_ID));
} else {
calendar = Calendar.getInstance(tz);
}
switch (unit_override) {
case Calendar.MILLISECOND:
if (1000 % interval_override == 0) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
if (interval_override > 1000) {
calendar.add(Calendar.MILLISECOND, -interval_override);
}
} else {
// from top of minute
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
}
break;
case Calendar.SECOND:
if (60 % interval_override == 0) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
if (interval_override > 60) {
calendar.add(Calendar.SECOND, -interval_override);
}
} else {
// from top of hour
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
}
break;
case Calendar.MINUTE:
if (60 % interval_override == 0) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
if (interval_override > 60) {
calendar.add(Calendar.MINUTE, -interval_override);
}
} else {
// from top of day
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
}
break;
case Calendar.HOUR_OF_DAY:
if (24 % interval_override == 0) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
if (interval_override > 24) {
calendar.add(Calendar.HOUR_OF_DAY, -interval_override);
}
} else {
// from top of month
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_MONTH, 1);
}
break;
case Calendar.DAY_OF_MONTH:
if (interval_override == 1) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_MONTH, 1);
} else {
// from top of year
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.MONTH, 0);
}
break;
case Calendar.DAY_OF_WEEK:
if (2 % interval_override == 0) {
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_WEEK, calendar.getFirstDayOfWeek());
} else {
// from top of year
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MONTH, 0);
calendar.set(Calendar.DAY_OF_WEEK, calendar.getFirstDayOfWeek());
}
unit_override = Calendar.DAY_OF_MONTH;
interval_override = 7;
break;
case Calendar.WEEK_OF_YEAR:
// from top of year
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.MONTH, 0);
break;
case Calendar.MONTH:
case Calendar.YEAR:
calendar.setTimeInMillis(ts);
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.MONTH, 0);
break;
default:
throw new IllegalArgumentException("Unexpected unit_overrides of type: "
+ unit_override);
}
if (calendar.getTimeInMillis() == ts) {
return calendar;
}
// TODO optimize a bit. We probably don't need to go past then back.
while (calendar.getTimeInMillis() <= ts) {
calendar.add(unit_override, interval_override);
}
calendar.add(unit_override, -interval_override);
return calendar;
}
/**
* Return the proper Calendar time unit as an integer given the string
* @param units The unit to parse
* @return An integer matching a Calendar.<UNIT> enum
* @throws IllegalArgumentException if the unit is null, empty or doesn't
* match one of the configured units.
* @since 2.3
*/
public static int unitsToCalendarType(final String units) {
if (units == null || units.isEmpty()) {
throw new IllegalArgumentException("Units cannot be null or empty");
}
final String lc = units.toLowerCase();
if (lc.equals("ms")) {
return Calendar.MILLISECOND;
} else if (lc.equals("s")) {
return Calendar.SECOND;
} else if (lc.equals("m")) {
return Calendar.MINUTE;
} else if (lc.equals("h")) {
return Calendar.HOUR_OF_DAY;
} else if (lc.equals("d")) {
return Calendar.DAY_OF_MONTH;
} else if (lc.equals("w")) {
return Calendar.DAY_OF_WEEK;
} else if (lc.equals("n")) {
return Calendar.MONTH;
} else if (lc.equals("y")) {
return Calendar.YEAR;
}
throw new IllegalArgumentException("Unrecognized unit type: " + units);
}
}