expiration

Cache expiration: forget a key after a lifetime or an idle spell

Wrap any EvictionStrategy in TimeToLive to remove a key a set time after it was written, or in TimeToIdle to remove it a set time after it was last used.

Rationale

Why. A kept value can go stale long before capacity asks for its room.

Key Concepts

What restarts a deadline

Role. An Insert restarts both. A Hit restarts only TimeToIdle. Nested in either order, the earlier deadline removes the key. Every event and evict also reaches the wrapped strategy.

Pattern. DECORATOR, OBSERVER

How It Works

Guarantees. No public parameter takes null and no public method returns it, declared with contrax's @PreCondition(IsNotNull.class) and @PostCondition(IsNotNull.class).

Lane

Purpose. Forgetting a key because of time rather than room.

Alternative. A capacity and an eviction alone, when a value never goes stale.

When to avoid. A value that must never be read past its deadline: a lookup answers the kept value until the Clockwork has run the removal.

Usage

A Clockwork driven by hand

final class HandClock implements Clockwork {
    private record Due(Instant at, Runnable then) { }

    private final List<Due> due = new ArrayList<>();
    private Instant now = Instant.EPOCH;

    @Override public Instant instant() { return now; }
    @Override public void after(Duration delay, Runnable then) { due.add(new Due(now.plus(delay), then)); }

    void advance(Duration by) {
        now = now.plus(by);
        List<Due> ready = due.stream().filter(each -> !each.at().isAfter(now)).toList();
        due.removeAll(ready);
        ready.forEach(each -> each.then().run());
    }
}

A 10-second lifetime: the read at second 9 does not extend it, so the read at second 10 loads again

var clock = new HandClock();
var loads = new AtomicInteger();
Cache<String, Integer> cache = new ReadThroughCacheFactory<String, Integer>(
        key -> Optional.of(loads.incrementAndGet()),
        new CachePolicy<String>(() -> new FixedSize<>(100),
                (forget, capacity) -> new TimeToLive<>(new LeastRecentlyUsed<>(forget, capacity),
                        clock, Duration.ofSeconds(10)),
                clock::instant)).get();

System.out.println(cache.apply("a"));
clock.advance(Duration.ofSeconds(9));
System.out.println(cache.apply("a"));
clock.advance(Duration.ofSeconds(1));
System.out.println(cache.apply("a"));

Output

Optional[1]
Optional[1]
Optional[2]

A 10-second TimeToIdle instead: reads at seconds 9 and 18 keep the key, then 10 idle seconds remove it

(forget, capacity) -> new TimeToIdle<>(new LeastRecentlyUsed<>(forget, capacity),
        clock, Duration.ofSeconds(10))

Reads at seconds 0, 9, 18 and 28

Optional[1]
Optional[1]
Optional[1]
Optional[2]

Both: the lifetime ends first at second 10, then idleness at second 16

(forget, capacity) -> new TimeToLive<>(
        new TimeToIdle<>(new LeastRecentlyUsed<>(forget, capacity), clock, Duration.ofSeconds(6)),
        clock, Duration.ofSeconds(10))

Reads at seconds 0, 5, 10 and 16

Optional[1]
Optional[1]
Optional[2]
Optional[3]

Dependencies

  • software.visionary:cache:2026.10.13 at Compile
  • software.visionary:clock:2026.10.13 at Compile
  • software.visionary.contrax:processor:2026.10.13 at Processor
  • software.visionary:junit-closure:2026.09.17 at Testing

Caveats

  • The Clockwork must read the same clock as the event timestamps: give CachePolicy clock::instant.

Views

Drawn from the release. A link in a view opens its source at the release tag.

architecture.svg
architecture.svg
classes.software.visionary.cache.eviction.expiration.svg
classes.software.visionary.cache.eviction.expiration.svg
packages.svg
packages.svg