TDD a CLI Caching Script - Part Six - Utility

2020-02-05

This is part six in a series about writing a general-purpose script to cache CLI output. In this brief post we'll make our script more useful as a utility to other scripts by adding some new options.

If you want to try your hand at implementing these features, checkout 780d577 locally. I'll omit my implementation here but will link to the appropriate diffs.

--check

We can implement --check to allow checking to see if the cache is fresh per the provided --ttl and --stale-while-revalidate options. The best way to communicate this freshness is with exit status, IMHO. This will allow a shell script to use cache --check as a conditional. We'll return 0 (truthy) if the cache is fresh and 1 (falsy) if it is not.

Example usage:

fresh() {
    cache --check --ttl 1 test-cache-key
}

if fresh; then
    echo "FRESH"
else
    echo "NOT FRESH"
fi

Here's the tests for the feature:

@test "--check returns 0 if the content exists and is inside the TTL/SWR" {
  run ./cache $TEST_KEY echo 1
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]

  run ./cache --stale-while-revalidate 1 --ttl 1 --check $TEST_KEY
  [ "$status" -eq 0 ]
  [ "$output" = "" ]
}

@test "--check returns 1 if the content exists but is outside the TTL/SWR" {
  run ./cache $TEST_KEY echo 1
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]

  wait_for_second_to_pass

  run ./cache --stale-while-revalidate 1 --ttl 0 --check $TEST_KEY
  [ "$status" -eq 1 ]
  [ "$output" = "" ]
}

@test "--check returns 1 if the content is not yet cached" {
  run ./cache --check $TEST_KEY
  [ "$status" -eq 1 ]
  [ "$output" = "" ]
}

Between the feature description and the tests, you can probably guess the implementation. You can check out the --check implementation as you wish.

--purge

Every cache needs a good mechanism for clearing the cached content on-demand. Maybe your cache key wasn't specific enough or outside forces have made the content stale before the TTL naturally expires. Whatever your reason, we'll support cache --purge <cache-key> to remove the content currently cached at the provided key.

Here's the tests for --purge:

@test "--purge exits with 0 if the content is not yet cached" {
  [ ! -f "$CACHE_DIR$TEST_KEY" ]

  run ./cache --purge $TEST_KEY
  [ "$status" -eq 0 ]
  [ "$output" = "" ]

  [ ! -f "$CACHE_DIR$TEST_KEY" ]
}

@test "--purge exits with 0 and removes the file if the content is cached" {
  touch "$CACHE_DIR$TEST_KEY"

  [ -f "$CACHE_DIR$TEST_KEY" ]

  run ./cache --purge $TEST_KEY
  [ "$status" -eq 0 ]
  [ "$output" = "" ]

  [ ! -f "$CACHE_DIR$TEST_KEY" ]
}

Try implementing this feature and then check out my implementation afterwards.

Closing

With a few small tweaks we've made our cache script more useful. This will wrap up the series for now, but I'm sure you'll see the cache script appear as a background character in future posts.


TDD a CLI Caching Script - Part Five - Async Updates / Stale-While-Revalidate

2020-02-01

This is part five in a series about writing a general-purpose script to cache CLI output. In this post, we'll be write about async updates and stale-while-revalidate.

Async Updates

There's a neat HTTP header stale-while-revalidate that allows for a server to return content that the server knows is stale while updating the content in the background. Instead of blocking while the content is updated, the server can return the stale response immediately and start doing the work to provide a fresh response next time. This can be helpful for situations where we're willing to sacrifice freshness for responsiveness.

We won't try to mimic all of the HTTP stale-while-revalidate concepts, but we can provide a simple --stale-while-revalidate SECONDS option to our script to allow serving content past the TTL while we refresh in the background.

Here's the rules for our --stale-while-revalidate:

  • If there's cached content

    • And we are inside the TTL

      • We serve the still-fresh content and do not trigger a background update.
    • And the TTL has expired but we're still in the stale-while-revalidate duration

      • We immediately return the stale content.
      • We trigger a background update.
    • And the TTL has expired and we're outside the stale-while-revalidate duration

      • We fall back to the synchronous behavior.
  • If there's no cached content

    • We keep to the original synchronous behavior.

Let's work through the test cases.

@test "--stale-while-revalidate does not trigger a background update if we're in the TTL" {
  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 1
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]
  [ -f "$CACHE_DIR$TEST_KEY" ]

  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 2
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]

  # The value has _not_ been updated in the background
  [ "$(cat "$CACHE_DIR$TEST_KEY")" = "1" ]
}

This fails because we're not parsing the option yet. We'll add this content to our cache script's options case statement:

        --stale-while-revalidate)
            stale_while_revalidate="$2"
            shift # drop the key
            shift # drop the value
            ;;

Now that test passes since it is testing existing behavior. We move on to our next test:

@test "--stale-while-revalidate triggers a background update if we're outside the TTL but inside the SWR seconds" {
  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 1
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]
  [ -f "$CACHE_DIR$TEST_KEY" ]

  sleep 1

  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 2
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]

  # The value _has_ been updated in the background
  [ "$(cat "$CACHE_DIR$TEST_KEY")" = "2" ]

  # and now the updated value is used
  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 3
  [ "$status" -eq 0 ]
  [ "$output" = "2" ]
}

On the second run, we're expecting to be outside our TTL but still inside our stale-while-revalidate (SWR for short) seconds. So we should return the cached output from the first run on the second run, but also trigger a background update.

This test fails on the second [ "$output" = "1" ] assertion because we are not yet considering the SWR.

Considering the SWR isn't too tricky. We'll modify our initial script to add

    if [ $remaining_time -lt $((ttl + stale_while_revalidate)) ]; then
        return 0
    fi

after our existing check

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

This change makes the previous assertion pass. Now we're failing on the [ "$(cat "$CACHE_DIR$TEST_KEY")" = "2" ] assertion because we're not triggering the background update for our cached content.

This part required some real thought to solve. We would like to call our script with the original script arguments in the background, something like:

${BASH_SOURCE[0]} $original_args &

${BASH_SOURCE[0]} is the current script. We'll add original_args=$* to the top of the file (after set -e) to preserve them before all the option-parsing shift-ing. The trailing & backgrounds the process.

That's close... We need to make sure we're not opting back into the same --stale-while-revalidate options or we'll end up calling ourself in a loop until the SWR expired. So we want the arguments except the SWR, right? That would work, but preserving/removing arguments could get tricky as time goes on. It might be easier to add a new option to opt-out of the SWR behavior and always treat the content as stale.

${BASH_SOURCE[0]} --force-stale $original_args &

We'll add a case for --force-stale to our option parsing.

               --force-stale)
                       force_stale=1
                       shift # drop the key
                       ;;

Next we replace

if fresh; then

with

if [ -z "$force_stale" ] && fresh; then

This means we'll never try to check for freshness if we're passing --force-stale.

Now, at the bottom of our script, right before our exit $status, we add our code to conditionally update the cached content in the background:

if [ "$update_in_background" = "1" ]; then
    # We re-run the original command with the original args + --force-stale to
    # prevent the possibility of leveraging --stale-while-revalidate again.
    #
    # the & puts this in the background
    #
    # shellcheck disable=SC2086
    ${BASH_SOURCE[0]} --force-stale $original_args &
fi

(We disable the shellcheck here because we intentionally do want spreading for $original_args.)

And to set $update_in_background, we'll replace

    if [ $remaining_time -lt $((ttl + stale_while_revalidate)) ]; then
        return 0
    fi

with

    if [ $remaining_time -lt $((ttl + stale_while_revalidate)) ]; then
        update_in_background=1
        return 0
    fi

That all feels right, but the test fails on the same [ "$output" = "1" ] line. If you echo $output in the test, you'll see that the STDOUT from the backgrounded process is getting into our $output. No problem, we can send it to /dev/null and trust tee to copy it to the proper cache file.

    ${BASH_SOURCE[0]} --force-stale $original_args &

becomes

    ${BASH_SOURCE[0]} --force-stale $original_args > /dev/null &

And the tests pass!

 ✓ initial run is uncached
 ✓ works for quoted arguments
 ✓ preserves the status code of the original command
 ✓ subsequent runs are cached
 ✓ respects a TTL
 ✓ only caches 0 exit status by default
 ✓ allows specifying exit statuses to cache
 ✓ allows specifying * to allow caching all statuses
 ✓ returns the cached exit status
 ✓ documents options with --help
 ✓ stops parsing arguments after --
 ✓ parses options before and after the cache key
 ✓ stops parsing options after the command starts
 ✓ --stale-while-revalidate does not trigger a background update if we're in the TTL
 ✓ --stale-while-revalidate triggers a background update if we're outside the TTL but inside the SWR seconds

15 tests, 0 failures

Testing for the remaining rules

We can codify the remaining rules outlined above in an already-passing test.

@test "--stale-while-revalidate falls back to synchronous behavior if we're outside the TTL and SWR seconds" {
  run ./cache --stale-while-revalidate 1 --ttl 1 $TEST_KEY echo 1
  [ "$status" -eq 0 ]
  [ "$output" = "1" ]

  wait_for_second_to_pass

  run ./cache --stale-while-revalidate 1 --ttl 0 $TEST_KEY echo 2
  [ "$status" -eq 0 ]
  [ "$output" = "2" ]
}

This test covers this rule:

  • If there's cached content

    • And the TTL has expired and we're outside the stale-while-revalidate duration

      • We fall back to the synchronous behavior.

And also implicitly covers this scenario in the first run:

  • If there's no cached content

    • We keep to the original synchronous behavior.

SWR Forever

One last useful scenario that springs to mind is that we might allow stale content of any age while we revalidate. Right now we can parse a very large number as a proxy for infinity, but we could support * or true or similar.

9999999999 is ~317 years. I feel aversion to passing a bunch of 9's here. But is that a visceral aversion to essentially arbitrary numbers or is there really an ergonomics need here?

I don't yet know how frequent this use case would be, so I'm reluctant to make changes to the code (minimal or otherwise) to support it. Passing a large number works and I can always add additional behavior later.

Closing

We update our --help response (after updating our test for --help first, naturally) and we're done.

You might want to read the full diff for this feature. It also includes a tweak to avoid sleep calls in the tests.

In the next (and probably final) entry in this series, we'll add two new options to make our script more useful: --purge and --check


TDD a CLI Caching Script - Part Four - GitHub Actions, Linux-compatibility, and shellcheck

2020-01-25

This is part four in a series about writing a general-purpose script to cache CLI output. Up to this point, our script has only worked in OS X. In this post, we'll add Linux compatibility, show how you can setup a GitHub action for running your bats tests, and talk about shellcheck for linting shell scripts.

GitHub Action / Linux compatibility

We've been TDD-ing our cache script development with bats tests, but they're only running locally and manually. I wanted to setup a GitHub Action to run the tests automatically on every push. I didn't know anything about GitHub Actions so this took awhile to get right.

Here's the final .github/workflows/tests.yml file:

name: CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v1

      - name: Setup bats
        run: git clone https://github.com/sstephenson/bats.git

      - name: Test
        run: CACHE_DIR="$GITHUB_WORKSPACE/" ./bats/bin/bats test

This is mostly self-explanatory, but making the tests pass required some changes to the tests and cache script for Linux compatibility (since the Action is running in Ubuntu).

Here's the full diff. The changes break down into three types:

  1. Quote every variable. I was getting [: too many arguments errors on lines like [ $output = "flounder - fish" ] because of the $output variable has spaces in it.
  2. Allow configuring the $CACHE_DIR in the tests because I wasn't able to write to $TMPDIR in the Action.
  3. GNU stat and grep behave differently than their OS X (BSD) counterparts. stat needs different arguments and grep --help exits with a different status code and has different copy.

With these small tweaks, the cache script now works fine in Linux and our tests pass in our GitHub Action (example).

shellcheck

Quoting variables made our script more compatible. What other best practices are we missing? How can we learn more? shellcheck is a fantastic utility for linting and finding bugs in your shell scripts.

Running shellcheck cache alerts us to a number of issues with our script. The most prevalent issue is SC2086: Double quote to prevent globbing and word splitting. At the bottom of the output there's a link to SC2086 on the shellcheck wiki to learn more. This is the same issue we saw above where we needed to quote variables in our test file.

Shellcheck also alerts us to two best practices $/${} is unnecessary on arithmetic variables and Don't quote rhs of =~, it'll match literally rather than as a regex.

Linters give us a common language to talk about issues/best-practices and further reading to make the potential problems clear and to explain the alternatives in a user-friendly way. Instead of internalizing the naive "quote everything, I guess" we can refine our understanding to get to the details of why.

Like most linters, you can add magic comments to ignore lines you don't want to change. In this case, we like all the suggestions, so we can use shellcheck cache -f diff | patch -p1 cache to have shellcheck generate a patch that we immediately pass to patch to apply the fixes to our script.

Not all issues can be auto-fixed. We have to clean up $/${} is unnecessary on arithmetic variables manually, but that's trivial to do.

As a neat bonus, you can run shellcheck against .bats files to find potential issues in your bats tests.

Here's the diff that applies all the shellcheck suggestions to our script and tests.

My goofy face

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