TDD a CLI Caching Script - Part Two - Option Parsing, TTL, and Exit Codes

This is part two in a series about writing a general-purpose script to cache CLI output. In part one we wrote our first iteration of the script. It works for permanently caching content.

In this post, we'll add support for a TTL to specify when the content is no longer fresh. We'll also update the script to only cache successful runs of the provided command.

Let's parse some options

Our cache script uses positional arguments. Positional arguments can be great when they're few but as the number of arguments grows, they can lead to ambiguity in parsing and confusion for the user.

We'll support the TTL as an option specified by --ttl NUMBER_OF_SECONDS. Example usage:

cache --ttl 90 cache-key ping -c 3 google.com

After the first run, this will return cached content for any subsequent runs in 90 seconds.

We write a naive test for the TTL cache.

@test "respects a TTL" {
  run ./cache --ttl 1 $TEST_KEY echo initial-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]

  run ./cache --ttl 1 $TEST_KEY echo new-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]

  sleep 1

  run ./cache --ttl 1 $TEST_KEY echo third-value
  [ "$status" -eq 0 ]
  [ $output = "third-value" ]
}

This caches an initial-value with a TTL of 1 second and then immediately tries to run for the same cache-key within that window. Because the TTL hasn't expired, the cached initial-value is returned. Next we sleep 1 second and the TTL has expired. The cached content is no longer valid so we execute our echo command, third-value is returned, and third-value is now cached.

(I don't love sleeping in tests, but this is the easiest way to test this.)

The test fails because our exit status is 127. That's "command not found." The problem here is that our positional arguments mean we're parsing --ttl as the cache key. Then we're trying to execute 1 cache-tests-key echo initial-value as our command. That won't work.

Worse, our invalid argument parsing means we've left behind a temporary file our script doesn't know how to clean up. rm $TMPDIR/--ttl puts us back in a clean state.

There's a number of ways to parse options in bash scripts. getopts is compelling but it only allows for single character flags and that feels a little restrictive. I could brew install gnu-getopt to get a getopt that supports long flag names, but the usage is a little opaque.

We'll go with a straightforward while [[ $# -gt 0 ]] with a case statement as suggested here.

We modify our cache script to look like this

#!/usr/bin/env bash

set -e

positional=()
while [[ $# -gt 0 ]]
do
    key="$1"

    case $key in
        --ttl)
            ttl="$2"
            shift # drop the key
            shift # drop the value
            ;;
        *)    # default
            positional+=("$1") # save it in an array for later
            shift # drop the argument
            ;;
    esac
done

set -- "${positional[@]}" # restore positional parameters
cache_key=$1
shift

cache_dir=${CACHE_DIR:-$TMPDIR}
cache_file="$cache_dir$cache_key"

if test -f $cache_file; then
    cat $cache_file
else
    "$@" | tee $cache_file
    exit ${PIPESTATUS[0]}
fi

We're draining our arguments (with shift) until none remain. If we find the --ttl we extract it. Otherwise we keep the positional arguments and set those as our argument list ($@).

bats test now shows the test now fails because the initial-value remains even after the content should have expired. That's because we haven't implemented cache expiration yet.

Cache expiration

Let's list the circumstances where a cache hit is valid:

  1. The cache file exists and no TTL is specified
  2. The cache file exists and the TTL has not expired

How do we tell if the TTL is expired? When we write the cache file, we're implicitly keeping track of the timestamp when the content was cached in the modified timestamp attribute of the cache file. If the current time is less than the modified timestamp + our TTL seconds, then the cache is still valid. Otherwise it is expired.

We can use stat to get the modified time of a file and date to get the system time. stat -f %m <FILENAME> and date +%s both return an epoch time which makes for easy integer math.

Let's update our concept of freshness to consider the TTL:

We'll replace

if test -f $cache_file; then

with

fresh () {
    # if the $cache_file doesn't exist, it can't be fresh
    if [ ! -f $cache_file ]; then
        return 1
    fi

    # if we don't have a ttl specifed, our $cache_file is
    # fresh-enough
    if [ -z "$ttl" ]; then
        return 0
    fi

    # if a ttl is specified, we need to check the last modified
    # timestamp on the $cache_file
    mtime=$(stat -f %m $cache_file)
    now=$(date +%s)
    remaining_time=$(($now - $mtime))

    if [ $remaining_time -lt $ttl ]; then
        return 0
    fi

    return 1
}

if fresh; then

In some languages, zero is falsy and non-zero numbers are truthy. But in shell scripts, as you've seen in exit statuses, zero indicates success and non-zero indicates failure. Writing our function to be consistent with other expected values means we can easily swap in our function for the previous test conditional.

Our fresh function returns a truthy value if the cache file exists and either no TTL is specified or the TTL hasn't passed.

Running bats test shows everything passing:

 ✓ initial run is uncached
 ✓ works for quoted arguments
 ✓ preserves the status code of the original command
 ✓ subsequent runs are cached
 ✓ respects a TTL

5 tests, 0 failures

Caching successful runs only

Our current script caches regardless of the exit status of the provided command. In some cases, this is what you want because the status won't change on subsequent runs. But if the command can fail intermittently or the status can change over time, then you might only want to cache certain statuses.

hub ci-status, for example, can return an exit status of 2 while a GitHub Check is pending. Neither the hub ci-status command nor the GitHub check has failed. A subsequent run could exit with another 2 if it is still pending or exit with a 0 or a 1 for the final outcome.

It feels like a good default is to only cache on zero exit statuses. This default is least likely to unintentionally cache bad content. We can then add an option for specifying other acceptable statuses to cache.

We'll add an initial test:

@test "only caches 0 exit status by default" {
  run ./cache $TEST_KEY exit 1
  [ "$status" -eq 1 ]
  [ ! -f "$TMPDIR$TEST_KEY" ]

  run ./cache $TEST_KEY exit 0
  [ "$status" -eq 0 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

This fails since we cache everything. We'll update our script to make this pass by replacing

    "$@" | tee $cache_file
    exit ${PIPESTATUS[0]}

with

    "$@" | tee $cache_file
    status=${PIPESTATUS[0]}

    if [[ $status -ne 0 ]]; then
        rm $cache_file
    fi

    exit $status

We're actually still writing the cache file, we're just deleting it immediately if the exit status wasn't valid. This could be optimized for a conditional write, but the ease of using tee here makes this conditional rm acceptable to me.

That test passes, so let's add a new test for specifying acceptable status codes. We're going to pass these in with --cache-status followed by space-separated statuses we want to allow to be cached. We'll need to quote the statuses if there's more than one.

@test "allows specifying exit statuses to cache" {
  run ./cache --cache-status "1 2" $TEST_KEY exit 0
  [ "$status" -eq 0 ]
  [ ! -f "$TMPDIR$TEST_KEY" ]

  run ./cache --cache-status "1 2" $TEST_KEY exit 1
  [ "$status" -eq 1 ]
  [ -f "$TMPDIR$TEST_KEY" ]

  rm "$TMPDIR$TEST_KEY"

  run ./cache --cache-status "1 2" $TEST_KEY exit 2
  [ "$status" -eq 2 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

This initially fails because we aren't parsing our option yet. We'll update our case statement to look like this:

    case $key in
        --ttl)
            ttl="$2"
            shift # drop the key
            shift # drop the value
            ;;
        --cache-status)
            acceptable_statuses="$2"
            shift # drop the key
            shift # drop the value
            ;;
        *)    # default
            positional+=("$1") # save it in an array for later
            shift # drop the argument
            ;;
    esac

Now our test fails because we're not considering the acceptable_statuses. We can replace the line

if [[ $status -ne 0 ]]; then

with

    acceptable_statuses=${acceptable_statuses:-0}
    if [[ ! " $acceptable_statuses " =~ " $status " ]]; then

to make this pass. We're checking whether the actual exit status is in our list of acceptable_statuses (which is 0 by default). If not, we remove the $cache_file.

Finally, it would be nice to allow caching any status with --cache-status "*" so let's support that.

The test:

@test "allows specifying * to allow caching all statuses" {
  run ./cache --cache-status "*" $TEST_KEY exit 3
  [ "$status" -eq 3 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

And we'll tweak the line

    if [[ ! " $acceptable_statuses " =~ " $status " ]]; then

to be

    if [[ $acceptable_statuses != "*" ]] && [[ ! " $acceptable_statuses " =~ " $status " ]]; then

And the test passes.

Returning the exit status of the cached command

That all works, but it is a little unfortunate that if we have a cache hit, we're always exiting with a status of 0. If we cache a command that exited with status of 2 and that 2 is meaningful, it would be better to return that original exit status.

We write a test:

@test "returns the cached exit status" {
  run ./cache --cache-status "*" $TEST_KEY exit 3
  [ "$status" -eq 3 ]

  run ./cache --cache-status "*" $TEST_KEY exit 9
  [ "$status" -eq 3 ]
}

That fails because we exit with the zero status on cache hits. Let's update the code. We'll add an else clause to our acceptable status check to persist the status code of the cached run:

    if [[ $acceptable_statuses != "*" ]] && [[ ! " $acceptable_statuses " =~ " $status " ]]; then
        rm $cache_file
    else
        echo $status > "$cache_file.cache-status"
    fi

Next we'll update the if fresh check to read that cached status:

if fresh; then
    status=$(cat "$cache_file.cache-status")
    cat $cache_file

Finally, we'll move our existing exit $status line to the bottom of the file since we always are exiting with a specific status now.

Closing

We've added a TTL and made our script smarter about what exit statuses are able to be cached. We're also returning the cached exit status now.

Here's the diff for adding the TTL and the diff for better handling status codes.

Because the command to run is only executed if the TTL has expired, you can use the cache script as a throttle/rate-limiter. The command cache --ttl 90 cache-key say "90 seconds have passed" run in a tight loop will only speak every 90 seconds.

Now that we're accepting options, we should probably respond to --help. We'll address that and more in part three.

My goofy face

Hi, I'm Jeffrey Chupp.
I solve problems, often with code.