How to Design a Pascal Cache Around Value Lifecycles

A cache is easy to start with a dictionary and hard to finish. Once it holds objects, something has to decide when an entry is too old, when the source behind it has changed, how much the cache may hold, and who frees a value that is replaced or evicted.

TCacheBase<TKey, TValue> in Sphere 10 Pascal Framework treats those decisions as policies chosen at construction. A derived cache supplies only how to fetch a value, how large it is and, optionally, how to tell that it is stale. This article walks through the lifecycle of a cached value and the policy that governs each stage.

Cached values with deliberate creation, use and retirement lifecycles.
Cached values with deliberate creation, use and retirement lifecycles.
💡 In short. Derive a cache from TCacheBase and choose its expiration, staleness, eviction and dispose policies so cached objects are fetched, replaced and freed predictably.

1. A first look

Reading through the cache is an indexed lookup by key. The sample's ReadText function takes a cache of TEntry objects keyed by string and returns the text of one entry.

Pascal
function ReadText(ACache: TEntryCache; const AKey: String): String;
var
  Entry: TEntry;
begin
  Entry := ACache[AKey];
  Result := Entry.Text;
end;

There is no try..finally and no Free, because the cache owns the object under idpFree, the dispose policy that a later section describes. The reference is borrowed. Use it immediately and copy out any values that must survive a later cache operation, since a replacement, an eviction or the destruction of the cache frees the object.

2. The lifecycle of a cached value

Reading a key that is not in the cache calls Fetch, stores the result and returns it. Later reads return the stored value until one of three things makes the entry due for replacement. Its age can exceed the expiration policy, a staleness check can report that the source has changed, or the application can invalidate it. The next read then fetches a new value and releases the old one.

Entries also leave without being replaced, either because the cache needs room or because the application removes them. In every case the outgoing value is released according to the dispose policy.

Cached, Due and Removed are names for this explanation. The cache does not expose them as a state value.

3. Give the cache its fetching and freshness rules

A cache class passes its policies to the inherited constructor and overrides up to three methods. The sample caches objects of a class named TEntry, keyed by string and loaded from a TSource object that the cache borrows.

Pascal
constructor TEntryCache.Create(ASource: TSource);
begin
  inherited Create(nil, idpFree, crpOldest, epSinceFetchedTime, nvpCacheNormally, 4096, TTimeSpan.FromMinutes(1), 0.1, svpCheckStaleOnDemand);
  FSource := ASource;
end;

function TEntryCache.Fetch(const AKey: String): TEntry;
begin
  Result := FSource.Load(AKey);
end;

function TEntryCache.EstimateSize(const AValue: TEntry): SizeInt;
begin
  Result := AValue.InstanceSize + (Length(AValue.Key) + Length(AValue.Text)) * SizeOf(Char);
end;

function TEntryCache.CheckStaleness(const AKey: String; AItem: TCachedItem<TEntry>): Boolean;
begin
  Result := AItem.Value.Revision <> FSource.Revision;
end;

The constructor arguments after the owner map onto the lifecycle as follows.

  • Dispose policy (idpFree). The cache frees a value when its entry is replaced or removed. idpNone leaves values with the caller.
  • Reap policy (crpOldest). Which entries are evicted first when space is needed. Other choices include crpLeastUsed, crpLargest, crpIdle and crpNone.
  • Expiration policy (epSinceFetchedTime). Age is measured from the fetch. epSinceLastAccessedTime gives a sliding lifetime and epNone disables expiry.
  • Null value policy (nvpCacheNormally). Whether a nil result is cached, returned without caching, or raised as an error.
  • Maximum capacity (4096). The budget, in the units that EstimateSize returns.
  • Expiration duration (one minute). The age limit used by the expiration policy.
  • Reap level (0.1). The fraction of capacity the cache aims to release when it has to make room.
  • Stale value policy (svpCheckStaleOnDemand). Calls CheckStaleness on each read. The default, svpAssumeNeverStale, never calls it.

EstimateSize is a budgeting figure chosen by the cache author. It is compared with the maximum capacity and is not a measurement of process memory.

When a subclass is more than the job needs, TActionCache<TKey, TValue> takes the fetch and size functions as callbacks instead.

Existing Delphi/VCL Framework demonstration
Existing Delphi/VCL Framework demonstration

This retained screenshot shows the repository's Delphi/VCL cache demonstration. The output in this article is from the separate executable sample.

4. Read through the cache

The default indexer, which ReadText used in the first look, returns the value for a key and fetches it when needed. Get returns the TCachedItem<TValue> wrapper instead, which also carries the entry's fetch time and size.

5. Expiry, staleness and invalidation

These three mechanisms answer different questions, and all of them take effect on the next read of the key.

Mechanism Question it answers How it is triggered
Expiry Is the entry too old? Automatically, from the expiration policy and duration.
Staleness Has the source changed since the fetch? CheckStaleness, when the stale value policy enables it.
Invalidation Has the application decided the entry is wrong? Invalidate(AKey).

A staleness check runs on every hit, so it should be cheap. The sample compares an integer revision stored in the value with the source's current revision, which avoids loading anything to find out that nothing changed.

Pascal
      Cache.Invalidate('A');

Invalidate marks one entry without removing it. Remove deletes an entry at once, and Flush empties the cache. The ItemFetching, ItemFetched, ItemRemoving and ItemRemoved events report each transition to one handler.

6. Limits

â„šī¸ The sample exercises reuse, staleness replacement, explicit invalidation and disposal on destruction. It does not wait for a timed expiry or fill the cache to force an eviction.
âš ī¸ Keep these limits in mind.
  • A borrowed value is safe only until the next operation that can replace or remove its entry. Sharing borrowed objects across threads needs synchronization of your own.
  • Anything the cache borrows, such as the source object in the sample, must outlive the cache.
  • Capacity is enforced against EstimateSize, so a poor estimate gives a poor bound.

The sample prints the number of fetches and of live value objects after each step.

Plain text
Two reads: fetches=1 live=1
After source change: fetches=2 live=1
After Invalidate: fetches=3 live=1
After cache destroyed: live=0

The cache unit also contains bulk-fetch and preloaded cache classes that share these policies.

7. Get the Framework and run the example

Get Sphere 10 Pascal Framework and follow its package instructions . Use the units directly, or build the optional Lazarus or Delphi runtime package.

The sample was compiled and run on Windows with Free Pascal 3.2.0, against source revision 2cc6558 . Its Build.ps1 accepts the Framework, compiler, Lazarus and output paths.


Download the complete runnable sample.

sample.zip
Download the complete worked example and build instructions (ZIP).

For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .

Author: Herman Schoenfeld