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

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. undef and 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 are unknown.

  • 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, or docker.

  • older_than - optional. As for "report".

  • prune - optional. The third value "prune" returned.

  • now - optional. The current time. It becomes generated_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_at and last_used are seconds since 1970.

  • age is generated_at minus last_used: null if the last use is not known, and 0 (not negative) if it is in the future.

  • size is the bytes Docker reports; present says whether Docker still has the image.

  • When Docker cannot be reached, docker.reachable is false, and size, present and the totals that count them are null.

  • With older_than, only the selected images are in images and counted in totals.

  • prune is null for a listing. After a prune, it holds removed, freed, forgotten and failed. 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 pull is not in the record and is never pruned.

  • older_than is in seconds. Use "parse_duration" to turn 30d into seconds.

  • prune and report want an App::Actions::Docker object, not a program name.

  • Pruning removes images by force. docker image rm -f removes 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.

  • prune returns a list. Write my ($text, $status, $data) = ....

  • Months are 30 days and years 365 days. 1mo is 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.