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.
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
:
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:
And also implicitly covers this scenario in the first run
:
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
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:
- 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.
- Allow configuring the
$CACHE_DIR
in the tests because I wasn't able to write to $TMPDIR
in the Action.
- 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.