"my pocket watch rules" by Cubosh is licensed under CC BY 2.0 .
The TL;DR#
I created a tool to limit how often commands are run. It’s called debounce and I like it a lot. You can skip right to the repository to start debouncing, but today, I really want to tell a story.
The Caveat#
Before I lay my soul bare, let me address in advance the kind of comment I anticipate receiving:
You can already do some or all of this with [some other tool]
I did this for the same reason I might solve a crossword puzzle. We don’t collectively give up on the puzzle because someone else has already solved it. The joy is in discovering the solution. For me, it’s a nice diversion. Some days when work has been a real slog and I haven’t gotten a lot of wins, working on something simple like this gives me an easy win and I just feel better about my life. Maybe this is my therapy. Please don’t take my therapy away from me, even if it’s just re-inventing wheels.
The Setup#
Back in 2023, I wrote about
is, which is essentially
an experiment in making it easier to write shell scripts for different
environments. In the intervening months I’ve used it basically every day and
I’m quite happy with it. Somewhere along the way, I thought I could use
is to prevent me from doing things like
installing a nightly neovim
build more than once per day.
The scenario is basically that in my dot-files I run installer scripts to take care of mundane tasks across the machines that I use. Whenever I want something new to happen across my environments I edit an installer script and run it to ensure that things work as expected. Often the first pass is broken in some way. I edit the script and run it again. I make it a point to do this in the script so that if I get distracted and move on to something else, my work is saved and I can commit it to my repo later. If I quickly whip something up on the command line, I often forget to add it to my dot-files.
The Problem#
The problem arises when a task in an installer script takes too long.
For instance, I may have a build step that installs the nightly neovim
build.
If the script fails after this point and requires multiple runs to fix, I have
to wait for the nightly build install to happen over and over. That wastes time
and network resources. I can do better.
Solution: Attempt the First#
To address this, I added support for is cli age
, which allows me to do
something like:
is cli age nvim gt 1 day && ./installer/nvim.sh
is cli age
will search my $PATH
for an exact match on the provided name and
then check its age. This is great, because I now have some granularity around
when neovim
might be installed. I can express it in days, minutes, or even
seconds.
is cli age [filename] [gt|lt] [integer] [hours|minutes|seconds]
That’s it. If you prefer a shorthand, the following aliases are available:
- hours | hour | h
- minutes | minute | m
- seconds | second | s
This allows us to express the same intent with fewer characters.
As an overall solution this is nice, but it breaks down really quickly for
some other cases. Imagine I want to check for updates to other software which
doesn’t have a nightly build. This particular example runs
ubi to take care of
installing/upgrading typos
.
is cli age typos gt 1 day && ubi --project crate-ci/typos --in ~/local/bin
Here I’m not installing a nightly build, but rather a regular release. So if I run my installer script 10 times and there hasn’t been a new release in the last day or so, it will try to upgrade the binary 10 times. Sub-optimal.
Taking a Detour with Find#
If you’re thinking “you can already do this with find
”, you’re not wrong.
Let’s look at this option.
find $(which nvim) -mtime +1 -exec ./installer/nvim.sh \;
It’s helpful to know that -mtime +1
means the file needs to be more than 1
day old. If you want it to be younger than 1 day, you would use -mtime -1
. If
we wanted to limit to 10 minutes, rather than a day:
find $(which nvim) -mmin +10 -exec ./installer/nvim.sh \;
So, to recap, -mtime
is modification days and -mmin
is modification
minutes. If we wanted to limit to 30 seconds, then I don’t believe find
has a
convenient way to do this. It’s not terrible, but I just don’t love it. Also,
don’t forget to add the \;
at the end of the line, or this just doesn’t work.
To me using find
is just not intuitive. I want a syntax that
- feels natural
- reads like English
- I don’t constantly have to look up
- works exactly the way I want it to
Solution: Attempt the Second#
As established above, knowing the age of an installed binary is helpful when it
comes to a nightly build, but when it comes to binaries which are released less
regularly, it’s not great. I figured I could cobble something really primitive together,
where I would touch
a file at a known location after a successful
installation attempt and skip subsequent installation attempts if a file exists
at the known location and is younger than a specified time.
is cli age
is meant to be used for things that exist in your $PATH
, so I
added an is fso age
subcommand, where fso
stands for “filesystem object” (a
link, a directory, a file, etc). This means we can now do something like:
is fso age ~/.bash_profile gt 24 hours && echo "this file is not new"
This gives us an easy way to ensure that the last modified time of a file is younger or older than a certain age. We will leverage this below, in order to create a simple Bash debouncer.
Creating a debounce Function in Bash#
What does debouncing mean? ChatGPT explains it as:
Think of debouncing like pressing a button that triggers an action. If you press the button multiple times in quick succession, it might cause the action to be triggered repeatedlyβsometimes unnecessarily. But, if the button is debounced, it only triggers the action once, no matter how many times you press it, as long as itβs within a set time window.
For our purposes, we can think of debouncing as a way of telling the Operating System to:
run this command, but only if it hasn’t successfully been run within the last X seconds/hours/days".
Consider the function below:
db() {
if [ $# -lt 3 ]; then
echo "π€¬ Not enough arguments provided. Usage: debounce 6 h something"
return
fi
# exit as early as possible if we can't create the cache dir
# test -d appears to be slightly faster (3ms?) than mkdir -p
cache_dir=~/.cache/debounce
test -d $cache_dir || mkdir -p $cache_dir
number=$1
units=$2
shift 2
# everything remaining is runnable
target="$*"
# file is $target with slashes converted to dashes
file=$(echo "$target" | tr / -)
debounce="$cache_dir/$file"
if [ -f "$debounce" ] && is fso age "$debounce" lt "$number" "$units"; then
echo "π₯ will not run $target more than once every $number $units"
return
fi
"$@" && touch "$debounce"
}
We can now run our function like db 12 hours npm install
.
To break it down, db
does the following things:
- requires 3 (or more) arguments, in the form of
db 12 hours npm install
- creates a cache directory, if none exists
- transforms the given command (in this case “npm install”) into a cache file name
- checks if the cache file exists and it’s younger than the provided age check (12
hours here), if this is true it
- prints a message to assure the user that no code will be run
- returns
- runs the command in all other cases
- if the command succeeds (exit code 0), it will
touch
the cache file, so that we have a timestamp which subsequentdb
runs can use as a reference time
This is simple and it works. It doesn’t require much, other than having is
available to do the file age comparison. For simple commands it is quite
straightforward. You can even look at the cache directory to see the names of
commands which have already been run, which means that a simplels
will tell
you when they were last successfully run to completion. It’s not bad.
However, this breaks down once you get longer and/or more complex commands.
- Do I need to start worrying if the length of the generated file names will exceed OS limits?
- What about
&&
and||
and other special shell characters?- How will I encode them in the file name?
- Will it still be readable? Is there some kind of security risk to this approach?
- What if I want to run the same command in different directories, but each needs their own cache file?
Solution: Attempt the Third#
I could work around the limitations of the previous function, but it would require writing more Bash code. I don’t mind a bit of Bash to get me where I need to be, but at some point I’m more comfortable doing this in a different language. I chose to re-implement this in Go. I could have done this in a number of other languages, but I chose Go because goreleaser makes it easy for me to distribute a binary that I can install via ubi. My other solutions already depended on is, so there was always going to be some kind of dependency.
The new binary is called debounce. Let’s try it out:
$ debounce 1 day installer/nvim.sh
# installer runs...
$ debounce 1 day installer/nvim.sh
π₯ will not run "installer/nvim.sh" more than once every 1 d
How much delay does debounce
introduce? On an M3 MacBook Air we get:
$ time debounce 1 day installer/nvim.sh
π₯ will not run "installer/nvim.sh" more than once every 1 d
real 0m0.005s
user 0m0.002s
sys 0m0.002s
π Not much delay at all. What if I want to check when the next run is scheduled, without executing the command?
debounce --status 1 day installer/nvim.sh
π cache location: /Users/olaf/.cache/debounce/bdb61146922d63f599045bde35c65d0606e002f88bc3099554c644ff153e11df
π§ cache last modified: Mon, 24 Feb 2025 21:37:13 EST
β²οΈ debounce interval: 24:00:00
π°οΈ cache age: 00:02:56
β³ time remaining: 23:57:03
That’s the available debounce
metadata. We can always wipe out the cache file to start fresh.
$ rm /Users/olaf/.cache/debounce/bdb61146922d63f599045bde35c65d0606e002f88bc3099554c644ff153e11df
$ debounce --status 1 day installer/nvim.sh
Cache file does not exist. "installer/nvim.sh" will run on next debounce
The Tips#
Running Multi-Step Commands#
Use bash -c
if your debounce command is really a series of chained commands.
debounce 2 s bash -c 'sleep 2 && date'
Careful with Your Quotes#
If you need your variables to expand only at the time when your command is running, use single quotes.
debounce 10 s zsh -c 'foo=$(date) && echo $foo'
The debounce in Your CI/CD Pipeline#
If you’re using debounce
in CI/CD, you’ll want to ensure that your cache
directories are preserved between runs. You may find the --cache-dir
flag
helpful here.
The debounce in Your Ansible#
Maybe you want to run database backups, but no more than once per day.
- name: Perform debounced database backup
command: debounce 1 day ./back-up-database.sh
I can think of other use cases, like making sure you’re not pushing to (or pulling from) Docker hub too often. How about using a cron to retry a query against a flaky web service?
*/5 * * * * /path/to/debounce 30 m /path/to/fetch-exchange-rates.pl
Under this scenario, fetch-exchange-rates.pl
will be run every 5 minutes
until it successfully fetches the latest exchange rates. Then it won’t try
again for another 30 minutes. At that point it will continue to run every 5
minutes until it once again achieves success. And so on.
The Conclusion#
There are lots of possibilities here. Think about where you re-run logic
unnecessarily. Maybe debounce
could help?
For any more questions, I refer you the debounce repository. There is no way to comment on this blog, so you’ll have to go to GitHub to tell me how much you hate this idea. π
Until next time!
Related posts: