You Should Use ISO-8601 for Time Durations

·

4 min read

Cache TTL

In writing cache logic, we often need to specify a time duration for the cache to expire. It's called TTL, or "Time To Live." Considering the following code:

Cache::remember(key: 'all-users', ttl: 60 * 60 * 24, function () {
  return User::all();
});

From the above code, we can probably guess intuitively that the unit of the TTL parameter is seconds. But we won't always be in this case:

Cache::remember(key: 'all-users', ttl: 60 * 3, function () {
  return User::all();
});

Can you still tell if it is 3 hours or 3 minutes?

To avoid confusion, I always pass the Carbon instance into it. Carbon is a famous PHP package that provides a fluent way to manipulate DateTime objects:

Cache::remember(key: 'all-users', ttl: Carbon::now()->addSeconds(60 * 3), function () {
  return User::all();
});

Now it is very clear that 60 * 3 is in seconds.

To avoid having magic numbers in our program, we often extract these literal values into constants:

final class UserRepository
{
    public const ALL_USER_CACHE_KEY = 'all-users';
    public const ALL_USER_CACHE_TTL = 60 * 3;

    public function getAllUsers()
    {
        return Cache::remember(
            key: self::ALL_USER_CACHE_KEY,
            ttl: Carbon::now()->addSeconds(self::ALL_USER_CACHE_TTL),
            function () {
                return User::all();
            },
        );
    }
}

Here the problem appears again. We can't tell if it is 3 hours or 3 minutes when we only see the line that defines the const ALL_USER_CACHE_TTL. In order to know if it is hours or minutes, we must search for the usage of this constant. Of course, we can add a comment with the constant definition:

/** Cache TTL (Seconds) **/
public const ALL_USER_CACHE_TTL = 60 * 3;

An alternative way is to just specify the unit in the constant name:

final class UserRepository
{
    public const ALL_USER_CACHE_KEY = 'all-users';
    public const ALL_USER_CACHE_TTL_SECONDS = 60 * 3;

    public function getAllUsers()
    {
        return Cache::remember(
            key: self::ALL_USER_CACHE_KEY,
            ttl: Carbon::now()->addSeconds(self::ALL_USER_CACHE_TTL_SECONDS),
            function () {
                return User::all();
            },
        );
    }
}

These methods are acceptable but ugly. The comment only hints at you when you are reading the constant definition, but not when you are using the constant. And to add the unit to the name of the constant? It is causing the name to become longer and contains too much information.

The Solution, ISO-8601

Many people know about the existence of ISO-8601. It's a standard format to represent time, and dates. For date time, it's separated by a literal T: 2023-01-10T09:32:00Z.

Besides the format to represent a specific point of time or date, ISO-8601 also specified a way to represent a duration of time. For example, P1M means 1 month, and PT42S means 42 seconds. We can have something like P1Y2M3DT4H5M6S to represent 1 year, 2 months, 3 days, 4 hours, 5 minutes, and 6 seconds long.

In PHP, the build-in class DateInterval are already based on this syntax:

$ttl = new DateInterval('PT3M');

But I think it is too less known. In Laravel's documentation on Cache, all examples are written like this:

Cache::store('redis')->put('bar', 'baz', 600); // 10 Minutes

this:

Cache::put('key', 'value', $seconds = 10);

or this:

Cache::put('key', 'value', now()->addMinutes(10));

If you checked the source code of Cache class, you will see that it uses DateInterval internally:

// https://github.com/laravel/framework/blob/cb6d25743e57eebe700d672a257fb6e8e09f7d5c/src/Illuminate/Cache/Repository.php#L528-L543

/**
 * Calculate the number of seconds for the given TTL.
 *
 * @param  \DateTimeInterface|\DateInterval|int  $ttl
 * @return int
 */
protected function getSeconds($ttl)
{
    $duration = $this->parseDateInterval($ttl);

    if ($duration instanceof DateTimeInterface) {
        $duration = Carbon::now()->diffInRealSeconds($duration, false);
    }

    return (int) ($duration > 0 ? $duration : 0);
}

// https://github.com/laravel/framework/blob/cb6d25743e57eebe700d672a257fb6e8e09f7d5c/src/Illuminate/Support/InteractsWithTime.php#L40-L53

/**
 * If the given value is an interval, convert it to a DateTime instance.
 *
 * @param  \DateTimeInterface|\DateInterval|int  $delay
 * @return \DateTimeInterface|int
 */
protected function parseDateInterval($delay)
{
    if ($delay instanceof DateInterval) {
        $delay = Carbon::now()->add($delay);
    }

    return $delay;
}

But the documentation didn't mention DateInterval at all! The DateInterval support are added back in 2017. In my opinion, using DateInterval with ISO-8601's duration syntax is much-much more readable than using just seconds or using Carbon.

Fixing Our Code

Now with the knowledge of ISO-8601 and DateInterval. We can refactor our code into this:

final class UserRepository
{
    public const ALL_USER_CACHE_KEY = 'all-users';
    public const ALL_USER_CACHE_TTL = 'PT3M';

    public function getAllUsers()
    {
        return Cache::remember(
            key: self::ALL_USER_CACHE_KEY,
            ttl: new DateInterval(self::ALL_USER_CACHE_TTL),
            function () {
                return User::all();
            },
        );
    }
}

From the constant name, you can tell that it holds a time duration. Looking at the value, PT3M, you will easily tell that it means 3 minutes, not 3 hours. Our code looks so much better now!