NAME
App::Actions::Cache - remember which Docker images action-run downloaded, and remove them
SYNOPSIS
Record that a run used some images:
use App::Actions::Cache;
my $cache = App::Actions::Cache->new;
$cache->remember('alpine:3.20', 'perl:5.40');
List them with their sizes (this is action-run cache):
use App::Actions::Docker;
my $docker = App::Actions::Docker->new;
print $cache->report(docker => $docker);
Remove the images not used for 30 days (action-run cache --prune --older-than 30d):
my $seconds = App::Actions::Cache::parse_duration('30d');
my ($text, $status, $pruned) = $cache->prune(docker => $docker, older_than => $seconds);
print $text;
Get the listing as data (action-run cache --json):
my $document = $cache->document(docker => $docker, reachable => 1);
Format sizes and times for people:
print App::Actions::Cache::format_size(1536); # 1.5 KB
print App::Actions::Cache::format_age(3 * 3600); # 3 hours ago
DESCRIPTION
When action-run runs a workflow, it downloads (pulls) Docker images and keeps them, so the next run is faster. Images can be large, so this module keeps a record of which images action-run pulled. Then action-run cache can list them and action-run cache --prune can remove them. Images you pulled yourself are not in the record, so they are never removed.
The record is a small JSON file. For each image, it holds how many runs used it and when it was last used. It does not hold sizes: those are asked from Docker each time, because an image can be removed or replaced outside action-run.
The file is:
ACTION_RUN_CACHE_FILE, if that environment variable is set and not empty;otherwise action-run/cache.json inside your cache directory (see "base_directory").
A missing or broken file counts as empty. That is safe: the images are still on the disk, and the next run records them again.
The file is replaced in one step (written to a new temporary file, then renamed), so a crash never leaves a half-written record. The new file can be read only by you.
METHODS
base_directory
my $directory = App::Actions::Cache::base_directory();
Returns your personal cache directory, where action-run keeps its files.
ARGUMENTS
None.
RETURNS
On Windows: %LOCALAPPDATA%, or %USERPROFILE%\AppData\Local. Elsewhere: $XDG_CACHE_HOME, or $HOME/.cache. If none of these is set: ./.cache.
SIDE EFFECTS
Reads environment variables.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
default_path
my $file = App::Actions::Cache::default_path();
Returns the record file described above.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
new
my $cache = App::Actions::Cache->new(file => $path);
Makes an object for one record file. The file is not read yet.
ARGUMENTS
file- optional. Default "default_path".
RETURNS
The new object.
SIDE EFFECTS
None.
API SPECIFICATION
INPUT
{ file => { type => 'string', optional => 1 } }
OUTPUT
{ type => 'object', isa => 'App::Actions::Cache' }
MESSAGES
None.
file
my $path = $cache->file;
Returns the record file this object uses.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
load
my $data = $cache->load;
Reads the record file, once. Later calls return the same data without reading the file again.
RETURNS
A hash reference: { version => 1, images => { NAME => { pulls, last_used } } }. A missing or broken file gives no images.
SIDE EFFECTS
Reads the file the first time.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'hashref' }
MESSAGES
None. A file that cannot be read or parsed is treated as empty.
save
$cache->save;
Writes the record file. If there are no images, the file is deleted instead. You rarely need this: "remember" and "forget" call it.
RETURNS
1.
SIDE EFFECTS
Creates the folder if needed. Writes a new temporary file (mode 0600) next to the record and renames it over the record.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'integer', memberof => [ 1 ] }
MESSAGES
+-------------------------------------------+---------------------------+----------------------------+
| Exception (croak) | Meaning | What to do |
+-------------------------------------------+---------------------------+----------------------------+
| cannot write a new cache file in 'DIR': | the folder is not | fix its permissions, or |
| ERR | writable | set ACTION_RUN_CACHE_FILE |
| cannot write 'TEMP': ERR | disk full, or I/O error | free some space |
| cannot replace 'FILE': ERR | rename failed | check FILE is not a folder |
+-------------------------------------------+---------------------------+----------------------------+
remember
my $count = $cache->remember(@images);
Records that a run used these images: adds 1 to each one's count and sets its last use to now.
ARGUMENTS
@images- image names.undefand empty names are skipped.
RETURNS
How many images were recorded.
SIDE EFFECTS
Saves the record file if the count is above 0.
EXAMPLE
$cache->remember($_) for @pulled;
API SPECIFICATION
INPUT
{ images => { type => 'array', element_type => 'string' } }
OUTPUT
{ type => 'integer', min => 0 }
MESSAGES
As for "save".
entries
my $entries = $cache->entries;
Lists the recorded images.
RETURNS
An array reference of hash references, sorted by image name, each with image, pulls and last_used (seconds since 1970, or undef if not known).
SIDE EFFECTS
Reads the record file the first time.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref' }
MESSAGES
None.
forget
my $count = $cache->forget(@images);
Removes images from the record only. The images stay in Docker.
RETURNS
How many of the names were in the record.
SIDE EFFECTS
Saves the record file (or deletes it, if it is now empty) when the count is above 0.
API SPECIFICATION
INPUT
{ images => { type => 'array', element_type => 'string' } }
OUTPUT
{ type => 'integer', min => 0 }
MESSAGES
As for "save".
report
print $cache->report(%options);
Makes the text action-run cache prints: one row per image with its size, when it was last used and how long ago, then a total.
ARGUMENTS
docker- optional. An App::Actions::Docker, to ask for the sizes. Without it (for example when Docker is not running), sizes areunknown.older_than- optional. Seconds. List only images last used at least this long ago. An image with no recorded last use is left out.now- optional. The current time, for tests.
RETURNS
The text, ending with a newline.
SIDE EFFECTS
Asks Docker for each image's size, if docker is given.
API SPECIFICATION
INPUT
{
docker => { type => 'object', isa => 'App::Actions::Docker', optional => 1 },
older_than => { type => 'integer', min => 0, optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
A size of missing means the record names an image that Docker no longer has. The text then says how many are missing.
prune
my ($text, $status, $pruned) = $cache->prune(docker => $docker, %options);
Removes the selected images from Docker and from the record.
ARGUMENTS
docker- required. An App::Actions::Docker.older_than- optional. Seconds. Remove only images last used at least this long ago. Without it, every recorded image is removed.now- optional. The current time, for tests.
RETURNS
A list of three values: the text to print; the exit status (0, or 1 if an image could not be removed); and a hash reference for the JSON document with removed, freed (bytes), forgotten (images Docker no longer had) and failed (names).
SIDE EFFECTS
Runs docker image rm -f for each selected image that Docker still has. Removes those images and the ones Docker no longer has from the record. An image that could not be removed stays in the record.
API SPECIFICATION
INPUT
{
docker => { type => 'object', isa => 'App::Actions::Docker' },
older_than => { type => 'integer', min => 0, optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'array', min => 3, max => 3 } # (string, 0 or 1, hashref)
MESSAGES
+--------------------------------------------+--------------------------------+---------------------------+
| Message | Meaning | What to do |
+--------------------------------------------+--------------------------------+---------------------------+
| pruning the image cache needs a Docker | no "docker" argument (croak) | pass one |
| command | | |
| Removed N images, SIZE freed. | success | |
| Forgot N images no longer on the daemon. | they were already gone | |
| Could not remove N images: | an image is in use by a | remove the container, and |
| IMAGE | container, for example; | prune again |
| | status 1 | |
+--------------------------------------------+--------------------------------+---------------------------+
And the messages of "save".
document
my $document = $cache->document(%options);
Returns the listing as data: the document action-run cache --json prints.
ARGUMENTS
docker- optional. An App::Actions::Docker.reachable- true if Docker answers. Only then are sizes asked for.docker_bin- optional. The Docker program to name in the document. Default:docker's, ordocker.older_than- optional. As for "report".prune- optional. The third value "prune" returned.now- optional. The current time. It becomesgenerated_at.
RETURNS
A hash reference, described in "JSON OUTPUT".
SIDE EFFECTS
Asks Docker for sizes, if reachable. Checks the document against "json_schema".
API SPECIFICATION
INPUT
{
docker => { type => 'object', isa => 'App::Actions::Docker', optional => 1 },
reachable => { type => 'boolean', optional => 1 },
docker_bin => { type => 'string', optional => 1 },
older_than => { type => 'integer', min => 0, optional => 1 },
prune => { type => 'hashref', optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+--------------------------------------------+------------------------------+-----------------+
| Exception | Meaning | What to do |
+--------------------------------------------+------------------------------+-----------------+
| App::Actions::Schema::Invalid: the | a bug: the document does not | report it; exit |
| action-run cache --json document does not | match its schema | status 70 |
| match App::Actions::Cache->json_schema: ...| | |
+--------------------------------------------+------------------------------+-----------------+
json_schema
my $schema = App::Actions::Cache->json_schema;
Returns the JSON schema of the cache --json document, as a new hash reference. action-run schema cache prints the same schema.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'hashref' }
MESSAGES
None.
json_schema_version
my $version = App::Actions::Cache->json_schema_version;
Returns the version of the document's shape. The document carries the same number as schema_version. It goes up whenever the shape changes.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'integer', min => 1 }
MESSAGES
None.
parse_duration
my $seconds = App::Actions::Cache::parse_duration('30d');
Turns a length of time, as a person types it, into seconds.
ARGUMENTS
$text- a whole number and a unit, with or without a space:30s,15m,12h,7d,2w,3mo,1y, or the words (2 days,1 hour). Case does not matter. A month is 30 days and a year 365 days, the same as in the age column.
RETURNS
The number of seconds, or undef if the text is not understood (no unit, an unknown unit, a negative number or a fraction).
SIDE EFFECTS
None.
EXAMPLE
App::Actions::Cache::parse_duration('2 days'); # 172800
App::Actions::Cache::parse_duration('2'); # undef: no unit
API SPECIFICATION
INPUT
{ text => { type => 'string', optional => 1 } }
OUTPUT
{ type => 'integer', min => 0, optional => 1 }
MESSAGES
None.
format_span
my $words = App::Actions::Cache::format_span($seconds);
Says a length of time in words, using the largest whole unit: 2 days, 1 hour, 1 year. It is used for thresholds (older than 2 days).
RETURNS
The words, in the language of App::Actions::I18N. A negative or invalid number counts as 0 (0 seconds).
API SPECIFICATION
INPUT
{ seconds => { type => 'integer', optional => 1 } }
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
format_age
my $words = App::Actions::Cache::format_age($seconds);
Says an age in words: just now under a minute, otherwise "format_span" followed by ago, for example 3 hours ago.
API SPECIFICATION
INPUT
{ seconds => { type => 'integer', optional => 1 } }
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
format_size
my $text = App::Actions::Cache::format_size($bytes);
Says a size in bytes in a short form: 500 B, 1.5 KB, 2.3 GB. One KB is 1024 bytes.
RETURNS
The text. undef, 0 or a negative number gives 0 B.
EXAMPLE
App::Actions::Cache::format_size(1536); # 1.5 KB
API SPECIFICATION
INPUT
{ bytes => { type => 'integer', optional => 1 } }
OUTPUT
{ type => 'string', min => 3 }
MESSAGES
None.
JSON OUTPUT
"document" returns one object. Every key is always there. A value that could not be found out is null:
{
"schema_version": 3,
"generated_at": 1792540920,
"cache_file": "/home/me/.cache/action-run/cache.json",
"docker": { "bin": "docker", "reachable": true },
"images": [
{ "image": "alpine:3.20", "pulls": 3, "last_used": 1792540800, "age": 120, "size": 12345678, "present": true },
{ "image": "perl:5.38", "pulls": 1, "last_used": 1792440000, "age": 100920, "size": null, "present": false }
],
"totals": { "images": 2, "size": 12345678, "present": 1, "missing": 1 },
"prune": null
}
generated_atandlast_usedare seconds since 1970.ageisgenerated_atminuslast_used:nullif the last use is not known, and 0 (not negative) if it is in the future.sizeis the bytes Docker reports;presentsays whether Docker still has the image.When Docker cannot be reached,
docker.reachableis false, andsize,presentand the totals that count them arenull.With
older_than, only the selected images are inimagesand counted intotals.pruneisnullfor a listing. After a prune, it holdsremoved,freed,forgottenandfailed. The document is printed even when a removal failed, and the exit status is then 1.
action-run schema cache prints the schema. The examples t/fixtures/cache.json, cache-empty.json, cache-unreachable.json and cache-prune.json are checked against it by t/schema.t.
COMMON PITFALLS
Only images action-run pulled are listed. An image you pulled with
docker pullis not in the record and is never pruned.older_thanis in seconds. Use "parse_duration" to turn30dinto seconds.pruneandreportwant an App::Actions::Docker object, not a program name.Pruning removes images by force.
docker image rm -fremoves an image even if a stopped container uses it. An image that a running container uses cannot be removed and is reported as failed.A broken record file is silently treated as empty. Nothing is lost (the images stay in Docker), but they are not listed until a run uses them again.
The object reads the file once. If another program changes the file after "load", this object does not see the change. Make a new object.
prunereturns a list. Writemy ($text, $status, $data) = ....Months are 30 days and years 365 days.
1mois not "the same day last month".
ENCODING
Image names are ASCII in practice (Docker requires it). The record file is JSON written as UTF-8 by JSON::PP. Its path may contain any characters your system allows, including non-ASCII and emoji; it is used as bytes. The words that "format_span" returns are in the current language of App::Actions::I18N and may contain non-ASCII characters (for example in Chinese); encode them when you print.
AUTHOR
Nigel Horne, njh@nigelhorne.com
FORMAL SPECIFICATION
This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.
How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞCache means the routine changes nothing; ΔCache means it can change the record. The lines below the rule are always true when the routine returns.
[IMAGE, PATH]
Entry ≙ [ pulls : ℕ; last_used : optional ℕ ]
┌─ Cache ───────────────────────────────────────────
│ file : PATH
│ images : IMAGE ⇸ Entry the record, once loaded
│ loaded : 𝔹
├───────────────────────────────────────────────────
│ loaded ⇒ (file ∈ files ∧ parses(file) ⇒ images = parse(file).images)
└───────────────────────────────────────────────────
persisted ⇔ (images = ∅ ⇒ file ¬in; files') ∧ (images ≠ ∅ ⇒ parse(file') = images)
selected(older, now) = { i ∈ dom images | older = ⊥ ∨
(images(i).last_used ≠ ⊥ ∧ now − images(i).last_used ≥ older) }
base_directory
┌─ BaseDirectory ───────────────────────────────────
│ r! : PATH
├───────────────────────────────────────────────────
│ Windows ⇒ r! = first non-empty of ⟨LOCALAPPDATA, USERPROFILE/AppData/Local, ./.cache⟩
│ ¬Windows ⇒ r! = first non-empty of ⟨XDG_CACHE_HOME, HOME/.cache, ./.cache⟩
└───────────────────────────────────────────────────
default_path
┌─ DefaultPath ─────────────────────────────────────
│ r! : PATH
├───────────────────────────────────────────────────
│ ACTION_RUN_CACHE_FILE ¬in; {⊥, ⟨⟩} ⇒ r! = ACTION_RUN_CACHE_FILE
│ otherwise r! = base_directory/action-run/cache.json
└───────────────────────────────────────────────────
new
┌─ New ─────────────────────────────────────────────
│ Cache'
│ file? : optional PATH
├───────────────────────────────────────────────────
│ file' = if file? ∈ {⊥, ⟨⟩} then default_path else file?
│ ¬loaded'
└───────────────────────────────────────────────────
file
┌─ File ────────────────────────────────────────────
│ ΞCache
│ r! : PATH
├───────────────────────────────────────────────────
│ r! = file
└───────────────────────────────────────────────────
load
┌─ Load ────────────────────────────────────────────
│ ΔCache
│ r! : DATA
├───────────────────────────────────────────────────
│ loaded ⇒ images' = images
│ ¬loaded ∧ (file ¬in; files ∨ ¬parses(file)) ⇒ images' = ∅
│ loaded' ∧ r! = { version ↦ 1, images ↦ images' }
└───────────────────────────────────────────────────
save
┌─ Save ────────────────────────────────────────────
│ ΞCache
│ r! : {1}
├───────────────────────────────────────────────────
│ persisted
│ images ≠ ∅ ⇒ mode(file') = 0600 ∧ file is replaced in one rename
└───────────────────────────────────────────────────
remember
┌─ Remember ────────────────────────────────────────
│ ΔCache
│ images? : seq IMAGE
│ r! : ℕ
├───────────────────────────────────────────────────
│ G = { i ∈ ran images? | i ≠ ⊥ ∧ i ≠ ⟨⟩ }
│ ∀ i ∈ G • images'(i).pulls = (images(i).pulls or 0) + count(i, images?)
│ ∧ images'(i).last_used = now
│ ∀ i ∈ dom images \ G • images'(i) = images(i)
│ r! = #⟨ i in images? | i ∈ G ⟩
│ r! > 0 ⇒ persisted
└───────────────────────────────────────────────────
entries
┌─ Entries ─────────────────────────────────────────
│ ΞCache
│ r! : seq (IMAGE × Entry)
├───────────────────────────────────────────────────
│ ran r! = images ∧ r! is sorted by IMAGE
└───────────────────────────────────────────────────
forget
┌─ Forget ──────────────────────────────────────────
│ ΔCache
│ images? : seq IMAGE
│ r! : ℕ
├───────────────────────────────────────────────────
│ images' = ran images? ⩤ images
│ r! = #(ran images? ∩ dom images)
│ r! > 0 ⇒ persisted
└───────────────────────────────────────────────────
report
┌─ Report ──────────────────────────────────────────
│ ΞCache
│ older?, now? : optional ℕ
│ r! : TEXT
├───────────────────────────────────────────────────
│ one row in r! for each i ∈ selected(older?, now?)
└───────────────────────────────────────────────────
prune
┌─ Prune ───────────────────────────────────────────
│ ΔCache
│ docker? : Docker
│ older? : optional ℕ
│ r! : TEXT × {0, 1} × DATA
├───────────────────────────────────────────────────
│ docker? = ⊥ ⇒ raises NeedsDocker
│ S = selected(older?, now)
│ gone = { i ∈ S | docker?.image_size(i) = ⊥ }
│ removed = { i ∈ S \ gone | docker?.image_remove(i) = 0 }
│ failed = S \ (gone ∪ removed)
│ images' = (gone ∪ removed) ⩤ images
│ second(r!) = 0 ⇔ failed = ∅
└───────────────────────────────────────────────────
document
┌─ Document ────────────────────────────────────────
│ ΞCache
│ reachable? : 𝔹
│ d! : DATA
├───────────────────────────────────────────────────
│ d!.images lists selected(older?, now?) with age = max(0, now? − last_used)
│ ¬reachable? ⇒ d!.totals.size = ⊥ ∧ ∀ e ∈ d!.images • e.size = ⊥
│ matches(d!, json_schema) ∨ raises Invalid
└───────────────────────────────────────────────────
json_schema
┌─ JsonSchema ──────────────────────────────────────
│ r! : SCHEMA
├───────────────────────────────────────────────────
│ r! = decode(JSON_SCHEMA) ∧ r! is a new object
└───────────────────────────────────────────────────
json_schema_version
┌─ JsonSchemaVersion ───────────────────────────────
│ r! : ℕ₁
├───────────────────────────────────────────────────
│ r! = JSON_SCHEMA_VERSION
└───────────────────────────────────────────────────
parse_duration
UNIT == { s ↦ 1, m ↦ 60, h ↦ 3600, d ↦ 86400, w ↦ 604800,
mo ↦ 2592000, y ↦ 31536000 } ∪ spelled-out synonyms
┌─ ParseDuration ───────────────────────────────────
│ text? : optional TEXT
│ r! : optional ℕ
├───────────────────────────────────────────────────
│ trim(text?) = n ⁀ spaces ⁀ u ∧ n ∈ DIGITS⁺ ∧ lc(u) ∈ dom UNIT
│ ⇒ r! = n × UNIT(lc(u))
│ otherwise r! = ⊥
└───────────────────────────────────────────────────
format_span
┌─ FormatSpan ──────────────────────────────────────
│ seconds? : optional ℤ
│ r! : TEXT
├───────────────────────────────────────────────────
│ s = max(0, seconds?) (0 if not a whole number)
│ u = the largest of second, minute, hour, day, month, year with size(u) ≤ s
│ (second if none)
│ r! = i18n("span." ⁀ u, count ↦ s div size(u))
└───────────────────────────────────────────────────
format_age
┌─ FormatAge ───────────────────────────────────────
│ seconds? : optional ℤ
│ r! : TEXT
├───────────────────────────────────────────────────
│ max(0, seconds?) < 60 ⇒ r! = "just now"
│ otherwise r! = format_span(seconds?) ⁀ " ago"
└───────────────────────────────────────────────────
format_size
┌─ FormatSize ──────────────────────────────────────
│ bytes? : optional ℤ
│ r! : TEXT
├───────────────────────────────────────────────────
│ bytes? = ⊥ ∨ bytes? ≤ 0 ⇒ r! = "0 B"
│ k = min(4, max { j | 1024ʲ ≤ bytes? })
│ k = 0 ⇒ r! = show(bytes?) ⁀ " B"
│ k > 0 ⇒ r! = show1(bytes? / 1024ᵏ) ⁀ " " ⁀ ⟨KB, MB, GB, TB⟩(k)
└───────────────────────────────────────────────────
STATE DIAGRAM
The object has two states (record not yet read, record in memory). Each image in the record goes through the states on the right:
Object:
new() load / entries / remember / forget / report /
--------> +----------+ prune / document (first call) +-----------+
| UNLOADED |----------------------------------->| LOADED |
+----------+ file read; missing or broken | (in |
file = no images | memory) |
+-----------+
| ^
remember / forget / | | no further
prune change images: | | file reads
save() writes the file +--+
One image in the record:
run pulls IMAGE remember(IMAGE): pulls+1, last_used=now
----------------> +-------------+ <-----------------------------+
| RECORDED |-------------------------------+
+-------------+
| | |
forget(IMAGE) | | | prune: Docker no longer has it
(Docker keeps it) | | v
| | +-------------+
| | | FORGOTTEN | counted in "forgotten"
| | +-------------+
| |
| | prune: docker image rm -f
| +-------------------+------------------+
| succeeded | | failed (in use)
v v v
+-------------+ +-------------+ +----------------+
| NOT IN | | REMOVED | | stays RECORDED |
| RECORD | | from Docker | | listed in |
+-------------+ | and record | | "failed"; |
+-------------+ | status 1 |
+----------------+
prune(older_than => N) only touches images with a last use at least N
seconds ago; the others stay RECORDED.
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.