Update: I've implemented several of the features suggested below and packaged the improved code into a dedicated project: bash-cache.
I display a number of expensive operations in my Bash prompt (e.g. Git status) that read from disk or even make network requests. Obviously, I want to still have a responsive prompt, and the result of these functions don't change all too often (though I also want to know fairly immediately when they do change, hence why I'm including them in the prompt).
To accomplish this I've created a _cache
function that will decorate another function, turning it into a memoized variant that runs no more than once per minute. I've used it successfully for some time now, but it's still far from perfect. The full function is included below, with additional annotations interspersed.
# Given a name and an existing function, create a new function called name that
# executes the same commands as the initial function.
# Used by pgem_decorate.
copy_function() {
local function="${1:?Missing function}"
local new_name="${2:?Missing new function name}"
declare -F "$function" &> /dev/null || {
echo "No such function $1"; return 1
}
Re-declare the body of function
, but with the function declaration replaced by new_name()
.
eval "$(echo "${new_name}()"; declare -f "$function" | tail -n +2)"
}
# Given a function - and optionally a list of environment variables - Decorates
# the function with a short-term caching mechanism, useful for improving the
# responsiveness of functions used in the prompt, at the expense of slightly
# stale data.
#
# Suggested usage:
# expensive_func() {
# ...
# } && _cache expensive_func PWD
#
# This will replace expensive_func with a new fuction that caches the result
# of calling expensive_func with the same arguments and in the same working
# directory too often. The original expensive_func can still be called, if
# necessary, as _orig_expensive_func.
#
# Reading/writing output to files is tricky, for a breakdown of the issues see
# http://stackoverflow.com/a/22607352/113632
#
# It'd be nice to do something like write out,err,exit to a single file (e.g.
# base64 encoded, newline separated), but uuencode isn't always installed.
_cache() {
func="${1:?"Must provide a function name to cache"}"
shift
First, copy the function we're decorating from func
to _orig_func
. For now it still also exists as func
, but that will be overwritten with the caching behavior soon.
copy_function "${func}" "_orig_${func}" || return
Then store the names of the environment variables that can affect the behavior of the function being cached (e.g. the output of git status
depends on the current directory so PWD
should be included).
local env="${func}:"
for v in "$@"; do
env="$env:\$$v"
done
Now dynamically construct a _cache_func
function which will invoke _orig_func
and cache its output.
This function creates a new directory with mktemp
, invokes _orig_func
writing the stdout, stderr, and return code of the function to out
, err
, and exit
files in the new directory, then finally symlinks the directory to a $cachepath
(see below). A symlink is used to ensure that the cache is always consistent - exit
will not exist until the function returns, while out
and err
may be partially-populated before then.
eval "$(cat <<EOF
_cache_$func() {
: "\${cachepath:?"Environment must include cachepath"}"
mkdir -p "/tmp/._cache"
local cmddir=\$(mktemp -d -p "/tmp/._cache")
_orig_$func "\$@" > "\$cmddir/out" 2> "\$cmddir/err"
echo \$? > "\$cmddir/exit"
# Add end-of-output marker to preserve trailing newlines
printf "EOF" >> "\$cmddir/out"
printf "EOF" >> "\$cmddir/err"
ln -sfn "\$cmddir" "\$cachepath" # atomic
}
EOF
)"
Now func
will be redefined to attempt to read the output from _cache_func
, rather than by invoking the expensive _orig_func
.
eval "$(cat <<EOF
$func() {
# Clean up stale caches in the background
(find "/tmp/._cache" \
-not -path "/tmp/._cache" \
-not -newermt '-1 minute' \
-delete &)
A hash of any arguments passed into the function, along with the function name and the values of any environment variables passed into _cache
, is computed to be used as the cachepath
location that the symlink created in _cache_func
will point to.
local arghash=\$(echo "\${*}$env" | md5sum | tr -cd '0-9a-fA-F')
local cachepath="/tmp/._cache/\$arghash"
If the symlinked files exist their contents will be returned; otherwise the original function will be invoked, cached, and then returned. If the cached files are due to be invalidated soon the original function is instead invoked asynchronously to update the cache behind the scenes.
# Read from cache - capture output once to avoid races
# They could be deleted inbetween checking the files exist *then* reading
local out err exit found
out=\$(cat "\$cachepath/out" 2>/dev/null)
err=\$(cat "\$cachepath/err" 2>/dev/null)
exit=\$(cat "\$cachepath/exit" 2>/dev/null)
if [[ "\$exit" == "" ]]; then
# No cache, execute in foreground
_cache_$func "\$@"
out=\$(cat "\$cachepath/out")
err=\$(cat "\$cachepath/err")
exit=\$(cat "\$cachepath/exit")
else
local found=\$(find "/tmp/._cache" \
-path "\$cachepath/exit" -newermt '-10 seconds')
if [[ "\$found" == "" ]]; then
# Cache exists but is old, refresh in background
( _cache_$func "\$@" & )
fi
fi
# Output cached result, trimming the EOF marker
printf "%s" "\${out%EOF}"
printf "%s" "\${err%EOF}" >&2
return "\${exit:-255}"
}
EOF
)"
}
As I said, this works, but there are several things I'd like to improve:
- There's a lot of subshells, command substitutions, and external commands being invoked.
- Worse, there's lots of disk I/O (two
find
calls and three or sixcat
calls on reads, and six writes every time the cache is updated). - Storing the function's results across three files makes reasoning about the caching more complex, as a reader can potentially see the cache in an inconsistent state.
I'd love to be able to use one file (or even zero) rather than three. As noted in a comment above I looked into Base64-encoding the output so I could store the stdout/stderr/exit-code all in one file, but I'm not aware of a cross-platform way to do so.
I also looked into using /dev/shm
to avoid actually writing the files to disk, but the contents of this directory would occasionally vanish even when they weren't expected to.
Another option that I haven't experimented with would be to use dynamic variables (e.g. __cache_$arghash_out
), but that would require some sort of garbage collection scheme which I don't have to deal with currently thanks to find -delete
.
I'd also welcome any general feedback about the idea and implementation, of course.
Update: I've implemented several of the features suggested below and packaged the improved code into a dedicated project: bash-cache.